From 354615fb85ee684169c8c2a8cb297e21733b5d56 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 5 Feb 2026 17:00:55 +0900 Subject: [PATCH 01/30] feat(firebase): add service account management and project creation - Add IAM API client for Service Account operations (list, create, download key) - Add GCP project API for listing available projects and adding Firebase - Extend OAuth scopes to include iam and cloud-platform permissions - Create Zod-based Service Account JSON validator with real-time feedback - Extend FirebaseDownloader with SA management methods - Add new wizard phases: no_projects, service_account_menu, paste_json - Support JSON paste with real-time validation and error display - Save service account keys to .clix/service-account.json --- src/lib/services/firebase/api/firebase-api.ts | 105 ++ src/lib/services/firebase/api/iam-api.ts | 219 ++++ src/lib/services/firebase/api/index.ts | 9 + src/lib/services/firebase/api/types.ts | 225 ++++ src/lib/services/firebase/downloader.ts | 175 ++- src/lib/services/firebase/index.ts | 19 +- src/lib/services/firebase/oauth/config.ts | 11 +- .../firebase/service-account-validator.ts | 221 ++++ src/lib/services/firebase/types.ts | 3 +- src/ui/components/FirebaseWizard.tsx | 1051 +++++++++++++++-- 10 files changed, 1941 insertions(+), 97 deletions(-) create mode 100644 src/lib/services/firebase/api/iam-api.ts create mode 100644 src/lib/services/firebase/service-account-validator.ts diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index 6e6a51d..5273159 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -10,14 +10,17 @@ import type { CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, + GcpProject, IosApp, ListAndroidAppsResponse, + ListGcpProjectsResponse, ListIosAppsResponse, ListProjectsResponse, Operation, } from './types'; const BASE_URL = 'https://firebase.googleapis.com/v1beta1'; +const RESOURCE_MANAGER_URL = 'https://cloudresourcemanager.googleapis.com/v1'; /** * Firebase Management API client. @@ -258,4 +261,106 @@ export class FirebaseApiClient { async createIosApp(projectId: string, request: CreateIosAppRequest): Promise { return this.createAppWithOperation(`/projects/${projectId}/iosApps`, request); } + + // ============================================================================ + // GCP Project and Firebase Project Creation Methods + // ============================================================================ + + /** + * Make an authenticated GET request to an external URL. + */ + private async requestUrl(url: string): Promise { + const token = await this.getAccessToken(); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`API error (${response.status}): ${error}`); + } + + return response.json() as Promise; + } + + /** + * Fetch paginated results from an external URL. + */ + private async fetchPaginatedUrl( + baseUrl: string, + extractor: (response: R) => T[] | undefined, + ): Promise { + const items: T[] = []; + let pageToken: string | undefined; + + do { + const url = new URL(baseUrl); + if (pageToken) { + url.searchParams.set('pageToken', pageToken); + } + + const response = await this.requestUrl(url.toString()); + + const extracted = extractor(response); + if (extracted) { + items.push(...extracted); + } + pageToken = response.nextPageToken; + } while (pageToken); + + return items; + } + + /** + * List GCP projects that the user has access to. + * + * This can be used to find projects that don't have Firebase yet, + * so Firebase can be added to them. + * + * @returns List of GCP projects + */ + async listGcpProjects(): Promise { + return this.fetchPaginatedUrl( + `${RESOURCE_MANAGER_URL}/projects`, + (response) => response.projects, + ); + } + + /** + * List GCP projects that don't have Firebase yet. + * + * Compares GCP projects with Firebase projects to find ones + * that can have Firebase added. + * + * @returns List of GCP projects without Firebase + */ + async listAvailableGcpProjects(): Promise { + const [gcpProjects, firebaseProjects] = await Promise.all([ + this.listGcpProjects(), + this.listProjects(), + ]); + + const firebaseProjectIds = new Set(firebaseProjects.map((p) => p.projectId)); + + return gcpProjects.filter( + (gcp) => gcp.lifecycleState === 'ACTIVE' && !firebaseProjectIds.has(gcp.projectId), + ); + } + + /** + * Add Firebase to an existing GCP project. + * + * This creates a Firebase project linked to the GCP project. + * The project must not already have Firebase. + * + * @param projectId - GCP project ID + * @returns Created Firebase project + */ + async addFirebaseToProject(projectId: string): Promise { + return this.createAppWithOperation(`/projects/${projectId}:addFirebase`, {}); + } } diff --git a/src/lib/services/firebase/api/iam-api.ts b/src/lib/services/firebase/api/iam-api.ts new file mode 100644 index 0000000..b6e2c74 --- /dev/null +++ b/src/lib/services/firebase/api/iam-api.ts @@ -0,0 +1,219 @@ +/** + * Google IAM REST API client. + * + * Manages service accounts and keys for Firebase projects. + * + * @module services/firebase/api/iam-api + */ + +import type { + CreateServiceAccountKeyRequest, + CreateServiceAccountRequest, + ListServiceAccountsResponse, + ServiceAccount, + ServiceAccountKey, +} from './types'; + +const IAM_BASE_URL = 'https://iam.googleapis.com/v1'; + +/** + * Google IAM API client. + * + * Uses the IAM REST API to manage service accounts and their keys. + */ +export class IamApiClient { + private getAccessToken: () => Promise; + + /** + * Create a new IAM API client. + * + * @param getAccessToken - Function to get a valid access token + */ + constructor(getAccessToken: () => Promise) { + this.getAccessToken = getAccessToken; + } + + /** + * Make an authenticated GET request to the IAM API. + */ + private async request(path: string): Promise { + const token = await this.getAccessToken(); + const url = `${IAM_BASE_URL}${path}`; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`IAM API error (${response.status}): ${error}`); + } + + return response.json() as Promise; + } + + /** + * Make an authenticated POST request to the IAM API. + */ + private async postRequest(path: string, body: unknown): Promise { + const token = await this.getAccessToken(); + const url = `${IAM_BASE_URL}${path}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`IAM API error (${response.status}): ${error}`); + } + + return response.json() as Promise; + } + + /** + * Fetch paginated results from the IAM API. + * + * @param basePath - Base path for the API endpoint + * @param extractor - Function to extract items from response + * @returns All items across all pages + */ + private async fetchPaginated( + basePath: string, + extractor: (response: R) => T[] | undefined, + ): Promise { + const items: T[] = []; + let pageToken: string | undefined; + + do { + const params = new URLSearchParams(); + if (pageToken) { + params.set('pageToken', pageToken); + } + + const query = params.toString(); + const path = query ? `${basePath}?${query}` : basePath; + const response = await this.request(path); + + const extracted = extractor(response); + if (extracted) { + items.push(...extracted); + } + pageToken = response.nextPageToken; + } while (pageToken); + + return items; + } + + /** + * List service accounts in a project. + * + * @param projectId - GCP project ID + * @returns List of service accounts + */ + async listServiceAccounts(projectId: string): Promise { + return this.fetchPaginated( + `/projects/${projectId}/serviceAccounts`, + (response) => response.accounts, + ); + } + + /** + * Get a specific service account. + * + * @param projectId - GCP project ID + * @param serviceAccountEmail - Service account email + * @returns Service account details + */ + async getServiceAccount(projectId: string, serviceAccountEmail: string): Promise { + return this.request( + `/projects/${projectId}/serviceAccounts/${serviceAccountEmail}`, + ); + } + + /** + * Create a new service account. + * + * @param projectId - GCP project ID + * @param accountId - Service account ID (part before @) + * @param displayName - Optional display name + * @param description - Optional description + * @returns Created service account + */ + async createServiceAccount( + projectId: string, + accountId: string, + displayName?: string, + description?: string, + ): Promise { + const request: CreateServiceAccountRequest = { + accountId, + serviceAccount: { + displayName, + description, + }, + }; + + return this.postRequest(`/projects/${projectId}/serviceAccounts`, request); + } + + /** + * Create a new key for a service account. + * + * The key is returned in the response and cannot be retrieved again. + * Store it securely. + * + * @param projectId - GCP project ID + * @param serviceAccountEmail - Service account email + * @returns Service account key with private key data (base64 encoded) + */ + async createServiceAccountKey( + projectId: string, + serviceAccountEmail: string, + ): Promise { + const request: CreateServiceAccountKeyRequest = { + privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE', + keyAlgorithm: 'KEY_ALG_RSA_2048', + }; + + return this.postRequest( + `/projects/${projectId}/serviceAccounts/${serviceAccountEmail}/keys`, + request, + ); + } + + /** + * Create a service account and generate a key in one operation. + * + * This is a convenience method that combines createServiceAccount and createServiceAccountKey. + * + * @param projectId - GCP project ID + * @param accountId - Service account ID (part before @) + * @param displayName - Optional display name + * @returns Object containing the service account and its key + */ + async createServiceAccountWithKey( + projectId: string, + accountId: string, + displayName?: string, + ): Promise<{ serviceAccount: ServiceAccount; key: ServiceAccountKey }> { + const serviceAccount = await this.createServiceAccount( + projectId, + accountId, + displayName, + 'Created by Clix CLI for Firebase Admin SDK', + ); + + const key = await this.createServiceAccountKey(projectId, serviceAccount.email); + + return { serviceAccount, key }; + } +} diff --git a/src/lib/services/firebase/api/index.ts b/src/lib/services/firebase/api/index.ts index 70386db..1d0d894 100644 --- a/src/lib/services/firebase/api/index.ts +++ b/src/lib/services/firebase/api/index.ts @@ -5,16 +5,25 @@ */ export { FirebaseApiClient } from './firebase-api'; +export { IamApiClient } from './iam-api'; export type { AndroidApp, AppConfigResponse, CreateAndroidAppRequest, CreateIosAppRequest, + CreateServiceAccountKeyRequest, + CreateServiceAccountRequest, FirebaseApiError, FirebaseProject, + GcpProject, IosApp, ListAndroidAppsResponse, + ListGcpProjectsResponse, ListIosAppsResponse, ListProjectsResponse, + ListServiceAccountsResponse, Operation, + ServiceAccount, + ServiceAccountJson, + ServiceAccountKey, } from './types'; diff --git a/src/lib/services/firebase/api/types.ts b/src/lib/services/firebase/api/types.ts index 1bc8fbd..bfb502e 100644 --- a/src/lib/services/firebase/api/types.ts +++ b/src/lib/services/firebase/api/types.ts @@ -201,3 +201,228 @@ export interface Operation { details?: unknown[]; }; } + +// ============================================================================ +// GCP Project Types +// ============================================================================ + +/** + * Google Cloud Platform project information. + * Used for listing GCP projects that can have Firebase added. + */ +export interface GcpProject { + /** + * Project ID (e.g., "my-project-id") + */ + projectId: string; + + /** + * Project display name. + */ + name: string; + + /** + * Project number. + */ + projectNumber: string; + + /** + * Project lifecycle state. + */ + lifecycleState: 'ACTIVE' | 'DELETE_REQUESTED' | 'DELETE_IN_PROGRESS'; + + /** + * Project creation time. + */ + createTime?: string; +} + +/** + * API response for listing GCP projects. + */ +export interface ListGcpProjectsResponse { + projects?: GcpProject[]; + nextPageToken?: string; +} + +// ============================================================================ +// Service Account Types +// ============================================================================ + +/** + * Google Cloud Service Account. + */ +export interface ServiceAccount { + /** + * Resource name (e.g., "projects/{project}/serviceAccounts/{email}") + */ + name: string; + + /** + * Project ID this service account belongs to. + */ + projectId: string; + + /** + * Unique ID. + */ + uniqueId: string; + + /** + * Service account email (e.g., "name@project.iam.gserviceaccount.com") + */ + email: string; + + /** + * Display name. + */ + displayName?: string; + + /** + * Description. + */ + description?: string; + + /** + * Whether the service account is disabled. + */ + disabled?: boolean; +} + +/** + * Request body for creating a service account. + */ +export interface CreateServiceAccountRequest { + /** + * Service account ID (the part before @ in the email). + * Must be between 6 and 30 characters, lowercase letters, digits, and hyphens. + */ + accountId: string; + + /** + * Service account details. + */ + serviceAccount: { + displayName?: string; + description?: string; + }; +} + +/** + * API response for listing service accounts. + */ +export interface ListServiceAccountsResponse { + accounts?: ServiceAccount[]; + nextPageToken?: string; +} + +/** + * Service account key. + */ +export interface ServiceAccountKey { + /** + * Resource name. + */ + name: string; + + /** + * Private key type. + */ + privateKeyType: string; + + /** + * Key algorithm. + */ + keyAlgorithm: string; + + /** + * Base64-encoded private key data (JSON format). + */ + privateKeyData: string; + + /** + * Key valid after time. + */ + validAfterTime: string; + + /** + * Key valid before time. + */ + validBeforeTime: string; +} + +/** + * Request body for creating a service account key. + */ +export interface CreateServiceAccountKeyRequest { + /** + * Private key type. Use 'TYPE_GOOGLE_CREDENTIALS_FILE' for JSON format. + */ + privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE' | 'TYPE_PKCS12_FILE'; + + /** + * Key algorithm. + */ + keyAlgorithm?: 'KEY_ALG_RSA_2048' | 'KEY_ALG_RSA_1024'; +} + +/** + * Firebase/Google Cloud Service Account JSON key file structure. + * This is the format of the downloaded JSON key file. + */ +export interface ServiceAccountJson { + /** + * Always "service_account". + */ + type: 'service_account'; + + /** + * GCP project ID. + */ + project_id: string; + + /** + * Private key ID. + */ + private_key_id: string; + + /** + * Private key in PEM format. + */ + private_key: string; + + /** + * Service account email. + */ + client_email: string; + + /** + * Client ID. + */ + client_id: string; + + /** + * Auth URI. + */ + auth_uri: string; + + /** + * Token URI. + */ + token_uri: string; + + /** + * Auth provider certificate URL. + */ + auth_provider_x509_cert_url: string; + + /** + * Client certificate URL. + */ + client_x509_cert_url: string; + + /** + * Universe domain (optional, for specialized environments). + */ + universe_domain?: string; +} diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index c690145..539fbcf 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -14,11 +14,19 @@ import type { CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, + GcpProject, IosApp, + ServiceAccount, + ServiceAccountJson, } from './api'; -import { FirebaseApiClient } from './api'; +import { FirebaseApiClient, IamApiClient } from './api'; import { getExpectedPaths } from './detector'; import { GoogleAuthClient } from './oauth'; +import { + parseBase64ServiceAccountJson, + parseServiceAccountJson, + type ServiceAccountValidationResult, +} from './service-account-validator'; import { type Platform, platformNeedsAndroid, platformNeedsIos } from './types'; /** @@ -62,6 +70,7 @@ export interface DownloadResult { export class FirebaseDownloader { private authClient: GoogleAuthClient; private apiClient: FirebaseApiClient | null = null; + private iamClient: IamApiClient | null = null; constructor() { this.authClient = new GoogleAuthClient(); @@ -270,5 +279,169 @@ export class FirebaseDownloader { async logout(): Promise { await this.authClient.logout(); this.apiClient = null; + this.iamClient = null; + } + + // ============================================================================ + // GCP Project and Firebase Project Management + // ============================================================================ + + /** + * List GCP projects that don't have Firebase yet. + * + * These projects can have Firebase added to them. + */ + async listAvailableGcpProjects(): Promise { + if (!(await this.isAuthenticated())) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + const api = this.ensureApiClient(); + return api.listAvailableGcpProjects(); + } + + /** + * Add Firebase to an existing GCP project. + * + * @param projectId - GCP project ID + * @returns Created Firebase project + */ + async addFirebaseToProject(projectId: string): Promise { + if (!(await this.isAuthenticated())) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + const api = this.ensureApiClient(); + return api.addFirebaseToProject(projectId); + } + + // ============================================================================ + // Service Account Management + // ============================================================================ + + /** + * Ensure IAM client is initialized. + */ + private ensureIamClient(): IamApiClient { + if (!this.iamClient) { + this.iamClient = new IamApiClient(() => this.authClient.getAccessToken()); + } + return this.iamClient; + } + + /** + * List service accounts in a project. + * + * @param projectId - GCP project ID + * @returns List of service accounts + */ + async listServiceAccounts(projectId: string): Promise { + if (!(await this.isAuthenticated())) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + const iam = this.ensureIamClient(); + return iam.listServiceAccounts(projectId); + } + + /** + * Create a service account and generate a key. + * + * @param projectId - GCP project ID + * @param accountId - Service account ID (e.g., "clix-firebase-admin") + * @param displayName - Optional display name + * @returns Service Account JSON key + */ + async createServiceAccountWithKey( + projectId: string, + accountId: string, + displayName?: string, + ): Promise { + if (!(await this.isAuthenticated())) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + const iam = this.ensureIamClient(); + + const { key } = await iam.createServiceAccountWithKey(projectId, accountId, displayName); + + // Decode base64 key data + const result = parseBase64ServiceAccountJson(key.privateKeyData); + if (!result.valid || !result.data) { + throw new Error(`Invalid service account key data: ${result.errors.join(', ')}`); + } + + return result.data; + } + + /** + * Download a new key for an existing service account. + * + * @param projectId - GCP project ID + * @param serviceAccountEmail - Service account email + * @returns Service Account JSON key + */ + async downloadServiceAccountKey( + projectId: string, + serviceAccountEmail: string, + ): Promise { + if (!(await this.isAuthenticated())) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + const iam = this.ensureIamClient(); + + const key = await iam.createServiceAccountKey(projectId, serviceAccountEmail); + + // Decode base64 key data + const result = parseBase64ServiceAccountJson(key.privateKeyData); + if (!result.valid || !result.data) { + throw new Error(`Invalid service account key data: ${result.errors.join(', ')}`); + } + + return result.data; + } + + /** + * Validate a Service Account JSON string. + * + * @param jsonString - JSON string to validate + * @returns Validation result + */ + validateServiceAccountJson(jsonString: string): ServiceAccountValidationResult { + return parseServiceAccountJson(jsonString); + } + + /** + * Save a Service Account JSON to the project's .clix directory. + * + * @param projectPath - Project root path + * @param serviceAccountJson - Service Account JSON data + * @returns Path where the file was saved + */ + async saveServiceAccountJson( + projectPath: string, + serviceAccountJson: ServiceAccountJson, + ): Promise { + const clixDir = path.join(projectPath, '.clix'); + const savePath = path.join(clixDir, 'service-account.json'); + + await fs.mkdir(clixDir, { recursive: true }); + await fs.writeFile(savePath, JSON.stringify(serviceAccountJson, null, 2), 'utf-8'); + + return savePath; + } + + /** + * Load existing Service Account JSON from the project's .clix directory. + * + * @param projectPath - Project root path + * @returns Service Account JSON or null if not found + */ + async loadServiceAccountJson(projectPath: string): Promise { + const savePath = path.join(projectPath, '.clix', 'service-account.json'); + + try { + const content = await fs.readFile(savePath, 'utf-8'); + const result = parseServiceAccountJson(content); + return result.valid && result.data ? result.data : null; + } catch { + return null; + } } } diff --git a/src/lib/services/firebase/index.ts b/src/lib/services/firebase/index.ts index b34f530..d89f544 100644 --- a/src/lib/services/firebase/index.ts +++ b/src/lib/services/firebase/index.ts @@ -7,7 +7,15 @@ */ // API types (public) -export type { AndroidApp, FirebaseProject, IosApp } from './api'; +export type { + AndroidApp, + FirebaseProject, + GcpProject, + IosApp, + ServiceAccount, + ServiceAccountJson, +} from './api'; +export { IamApiClient } from './api'; // Detection and validation export { detectFirebaseConfig, getExpectedPaths } from './detector'; @@ -23,6 +31,15 @@ export { FirebaseService } from './firebase-service'; export type { AuthResult, OAuthFlowState, OAuthFlowStatus, OAuthTokens } from './oauth'; export { getOAuthConfigurationError, isOAuthConfigured } from './oauth'; +// Service Account validation +export { + parseBase64ServiceAccountJson, + parseServiceAccountJson, + quickValidateServiceAccountJson, + type ServiceAccountValidationResult, + validateServiceAccountJson, +} from './service-account-validator'; + // Types export * from './types'; diff --git a/src/lib/services/firebase/oauth/config.ts b/src/lib/services/firebase/oauth/config.ts index 5d3a1cf..a20093e 100644 --- a/src/lib/services/firebase/oauth/config.ts +++ b/src/lib/services/firebase/oauth/config.ts @@ -156,9 +156,16 @@ export const GOOGLE_OAUTH_CONFIG = { /** * OAuth scopes required for Firebase Management API. - * Using full firebase scope for listing projects/apps, downloading configs, and creating apps. + * + * - firebase: List projects/apps, download configs, create apps + * - iam: Service Account management (create, list, generate keys) + * - cloud-platform: GCP project access (list projects, add Firebase to existing projects) */ - scopes: ['https://www.googleapis.com/auth/firebase'], + scopes: [ + 'https://www.googleapis.com/auth/firebase', + 'https://www.googleapis.com/auth/iam', + 'https://www.googleapis.com/auth/cloud-platform', + ], /** * Local redirect URI for OAuth callback. diff --git a/src/lib/services/firebase/service-account-validator.ts b/src/lib/services/firebase/service-account-validator.ts new file mode 100644 index 0000000..4ae0d9d --- /dev/null +++ b/src/lib/services/firebase/service-account-validator.ts @@ -0,0 +1,221 @@ +/** + * Service Account JSON validation utilities. + * + * Validates Firebase/Google Cloud Service Account JSON key files. + * + * @module services/firebase/service-account-validator + */ + +import { z } from 'zod'; + +/** + * Firebase/Google Cloud Service Account JSON schema. + * + * This is the structure of the JSON key file downloaded from + * Firebase Console or Google Cloud Console. + */ +export const ServiceAccountJsonSchema = z.object({ + // Type must be exactly "service_account" + type: z.literal('service_account'), + + // Project ID (required) + project_id: z.string().min(1, 'project_id is required'), + + // Private key ID (required) + private_key_id: z.string().min(1, 'private_key_id is required'), + + // Private key in PEM format (required) + private_key: z + .string() + .min(1, 'private_key is required') + .refine( + (key) => key.includes('-----BEGIN') && key.includes('PRIVATE KEY-----'), + 'Invalid private_key format (must be PEM format)', + ), + + // Service account email (required) + client_email: z + .string() + .email('Invalid client_email format') + .refine( + (email) => email.endsWith('.iam.gserviceaccount.com'), + 'client_email must be a service account email (*.iam.gserviceaccount.com)', + ), + + // Client ID (required) + client_id: z.string().min(1, 'client_id is required'), + + // Auth URI (required) + auth_uri: z.string().url('Invalid auth_uri URL'), + + // Token URI (required) + token_uri: z.string().url('Invalid token_uri URL'), + + // Auth provider certificate URL (required) + auth_provider_x509_cert_url: z.string().url('Invalid auth_provider_x509_cert_url'), + + // Client certificate URL (required) + client_x509_cert_url: z.string().url('Invalid client_x509_cert_url'), + + // Universe domain (optional, for specialized environments) + universe_domain: z.string().optional(), +}); + +/** + * Validated Service Account JSON type. + */ +export type ValidatedServiceAccountJson = z.infer; + +/** + * Validation result. + */ +export interface ServiceAccountValidationResult { + /** + * Whether the JSON is valid. + */ + valid: boolean; + + /** + * Validated data if valid. + */ + data?: ValidatedServiceAccountJson; + + /** + * Error messages if invalid. + */ + errors: string[]; +} + +/** + * Parse and validate a Service Account JSON string. + * + * @param jsonString - JSON string to validate + * @returns Validation result with parsed data or errors + */ +export function parseServiceAccountJson(jsonString: string): ServiceAccountValidationResult { + // 1. Try to parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(jsonString); + } catch (e) { + const message = e instanceof Error ? e.message : 'Invalid JSON'; + return { + valid: false, + errors: [`JSON parse error: ${message}`], + }; + } + + // 2. Validate against schema + return validateServiceAccountJson(parsed); +} + +/** + * Validate a parsed object against the Service Account JSON schema. + * + * @param json - Parsed JSON object to validate + * @returns Validation result with parsed data or errors + */ +export function validateServiceAccountJson(json: unknown): ServiceAccountValidationResult { + const result = ServiceAccountJsonSchema.safeParse(json); + + if (result.success) { + return { + valid: true, + data: result.data, + errors: [], + }; + } + + // Extract error messages + const errors = result.error.issues.map((issue) => { + const path = issue.path.join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }); + + return { + valid: false, + errors, + }; +} + +/** + * Quick validation result for UI feedback. + */ +export interface QuickValidationResult { + /** + * Whether the string is valid JSON. + */ + isJson: boolean; + + /** + * Whether the JSON has type: "service_account". + */ + isServiceAccount: boolean; + + /** + * Whether the JSON has a valid-looking private key. + */ + hasPrivateKey: boolean; + + /** + * Project ID if detected. + */ + projectId?: string; + + /** + * Client email if detected. + */ + clientEmail?: string; +} + +/** + * Perform quick validation for real-time UI feedback. + * + * This is a lightweight check that doesn't validate all fields, + * just enough to show immediate feedback to the user. + * + * @param jsonString - JSON string to check + * @returns Quick validation result + */ +export function quickValidateServiceAccountJson(jsonString: string): QuickValidationResult { + try { + const parsed = JSON.parse(jsonString) as Record; + + return { + isJson: true, + isServiceAccount: parsed?.type === 'service_account', + hasPrivateKey: + typeof parsed?.private_key === 'string' && parsed.private_key.includes('PRIVATE KEY'), + projectId: typeof parsed?.project_id === 'string' ? parsed.project_id : undefined, + clientEmail: typeof parsed?.client_email === 'string' ? parsed.client_email : undefined, + }; + } catch { + return { + isJson: false, + isServiceAccount: false, + hasPrivateKey: false, + }; + } +} + +/** + * Decode a base64-encoded Service Account JSON and validate it. + * + * This is useful for processing keys downloaded from the IAM API, + * which return the key data as base64-encoded JSON. + * + * @param base64Data - Base64-encoded JSON string + * @returns Validation result + */ +export function parseBase64ServiceAccountJson(base64Data: string): ServiceAccountValidationResult { + try { + const jsonString = Buffer.from(base64Data, 'base64').toString('utf-8'); + return parseServiceAccountJson(jsonString); + } catch (e) { + const message = e instanceof Error ? e.message : 'Invalid base64 data'; + return { + valid: false, + errors: [`Base64 decode error: ${message}`], + }; + } +} diff --git a/src/lib/services/firebase/types.ts b/src/lib/services/firebase/types.ts index 75d1520..cb57885 100644 --- a/src/lib/services/firebase/types.ts +++ b/src/lib/services/firebase/types.ts @@ -230,7 +230,8 @@ export type CredentialAction = | { type: 'help'; topic: keyof typeof FIREBASE_HELP_URLS } | { type: 'download' } | { type: 'skip' } - | { type: 'done' }; + | { type: 'done' } + | { type: 'setup_service_account' }; /** * Wizard phase states. diff --git a/src/ui/components/FirebaseWizard.tsx b/src/ui/components/FirebaseWizard.tsx index a17f645..50f5c85 100644 --- a/src/ui/components/FirebaseWizard.tsx +++ b/src/ui/components/FirebaseWizard.tsx @@ -15,10 +15,16 @@ import { type FirebaseProject, FirebaseService, type FirebaseSetupResult, + type GcpProject, type IosApp, isOAuthConfigured, + parseServiceAccountJson, platformNeedsAndroid, platformNeedsIos, + quickValidateServiceAccountJson, + type ServiceAccount, + type ServiceAccountJson, + type ServiceAccountValidationResult, type WizardPhase, } from '@/lib/services/firebase'; import { detectProjectType } from '@/lib/services/project-detector'; @@ -51,7 +57,17 @@ type ExtendedWizardPhase = | 'no_apps_found' | 'create_android_app' | 'create_ios_app' - | 'creating_app'; + | 'creating_app' + // New phases for no projects and service account + | 'no_projects' + | 'select_gcp_project' + | 'adding_firebase' + | 'service_account_menu' + | 'select_service_account' + | 'create_service_account' + | 'creating_service_account' + | 'paste_service_account' + | 'saving_service_account'; /** * No apps found context (which platform apps are missing). @@ -77,6 +93,89 @@ function platformNeedsIosWithUnknown(platform: FirebaseDetectionResult['platform return platformNeedsIos(platform) || platform === 'unknown'; } +/** + * Get Firebase project ID from detection result. + * Tries Android config first, then iOS config. + */ +function getProjectIdFromResult(result: FirebaseDetectionResult | null): string | undefined { + // Android config (GoogleServicesJson) has project_info.project_id + if (result?.android?.content && 'project_info' in result.android.content) { + return result.android.content.project_info?.project_id; + } + // iOS config (GoogleServiceInfoPlist) has PROJECT_ID + if (result?.ios?.content && 'PROJECT_ID' in result.ios.content) { + return result.ios.content.PROJECT_ID; + } + return undefined; +} + +/** + * Build Android-specific menu items. + */ +function buildAndroidMenuItems( + result: FirebaseDetectionResult, + needsAndroid: boolean, +): MenuAction[] { + if (!needsAndroid) return []; + + const items: MenuAction[] = []; + + if (!result.android?.valid) { + items.push({ + id: 'redetect-android', + label: 'Re-detect google-services.json', + description: result.android ? 'File found but invalid' : 'File not found', + action: { type: 'redetect_platform', platform: 'android' }, + }); + } + if (result.android) { + items.push({ + id: 'validate-android', + label: 'Validate google-services.json', + action: { type: 'validate', platform: 'android' }, + }); + } + items.push({ + id: 'help-android', + label: 'Help: Download google-services.json', + action: { type: 'help', topic: 'downloadConfig' }, + }); + + return items; +} + +/** + * Build iOS-specific menu items. + */ +function buildIosMenuItems(result: FirebaseDetectionResult, needsIos: boolean): MenuAction[] { + if (!needsIos) return []; + + const items: MenuAction[] = []; + + if (!result.ios?.valid) { + items.push({ + id: 'redetect-ios', + label: 'Re-detect GoogleService-Info.plist', + description: result.ios ? 'File found but invalid' : 'File not found', + action: { type: 'redetect_platform', platform: 'ios' }, + }); + } + if (result.ios) { + items.push({ + id: 'validate-ios', + label: 'Validate GoogleService-Info.plist', + action: { type: 'validate', platform: 'ios' }, + }); + } + items.push({ + id: 'help-ios', + label: 'Help: Download GoogleService-Info.plist', + action: { type: 'help', topic: 'downloadConfig' }, + }); + + return items; +} + /** * Build menu items based on detection result. */ @@ -102,51 +201,17 @@ function buildMenuItems(result: FirebaseDetectionResult): MenuAction[] { }); } - // Android actions - if (needsAndroid) { - if (!result.android?.valid) { - items.push({ - id: 'redetect-android', - label: 'Re-detect google-services.json', - description: result.android ? 'File found but invalid' : 'File not found', - action: { type: 'redetect_platform', platform: 'android' }, - }); - } - if (result.android) { - items.push({ - id: 'validate-android', - label: 'Validate google-services.json', - action: { type: 'validate', platform: 'android' }, - }); - } - items.push({ - id: 'help-android', - label: 'Help: Download google-services.json', - action: { type: 'help', topic: 'downloadConfig' }, - }); - } + // Platform-specific actions (extracted to reduce complexity) + items.push(...buildAndroidMenuItems(result, needsAndroid)); + items.push(...buildIosMenuItems(result, needsIos)); - // iOS actions - if (needsIos) { - if (!result.ios?.valid) { - items.push({ - id: 'redetect-ios', - label: 'Re-detect GoogleService-Info.plist', - description: result.ios ? 'File found but invalid' : 'File not found', - action: { type: 'redetect_platform', platform: 'ios' }, - }); - } - if (result.ios) { - items.push({ - id: 'validate-ios', - label: 'Validate GoogleService-Info.plist', - action: { type: 'validate', platform: 'ios' }, - }); - } + // Service Account setup (if OAuth is configured and project is configured) + if (isOAuthConfigured() && result.android?.valid) { items.push({ - id: 'help-ios', - label: 'Help: Download GoogleService-Info.plist', - action: { type: 'help', topic: 'downloadConfig' }, + id: 'setup-service-account', + label: '🔑 Setup Service Account', + description: 'Create or configure Firebase Admin SDK credentials', + action: { type: 'setup_service_account' }, }); } @@ -392,17 +457,393 @@ function NoAppsFoundPhase({ marginY={1} > - - No Apps Found - - - - No apps are registered in this Firebase project. - - - Would you like to create a new app? + + No Apps Found + + + + No apps are registered in this Firebase project. + + + Would you like to create a new app? + + + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +/** + * Create app input phase component. + */ +function CreateAppInputPhase({ + platform, + onSubmit, + onCancel, +}: { + platform: 'android' | 'ios'; + onSubmit: (identifier: string, displayName?: string) => void; + onCancel: () => void; +}): React.ReactElement { + const [identifier, setIdentifier] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [stage, setStage] = useState<'identifier' | 'displayName'>('identifier'); + + const isAndroid = platform === 'android'; + const identifierLabel = isAndroid ? 'Package Name' : 'Bundle ID'; + const identifierPlaceholder = isAndroid ? 'com.example.app' : 'com.example.app'; + const title = isAndroid ? 'Create Android App' : 'Create iOS App'; + + useCancelInput(onCancel); + + const handleIdentifierSubmit = useCallback(() => { + if (identifier.trim()) { + setStage('displayName'); + } + }, [identifier]); + + const handleDisplayNameSubmit = useCallback(() => { + onSubmit(identifier.trim(), displayName.trim() || undefined); + }, [identifier, displayName, onSubmit]); + + return ( + + + {title} + + + {stage === 'identifier' ? ( + <> + + + {identifierLabel}: (e.g., {identifierPlaceholder}) + + + + {'> '} + + + + Enter to continue · Esc/Ctrl+C cancel + + + ) : ( + <> + + + {identifierLabel}: {identifier} + + + + + Display Name: (optional) + + + + {'> '} + + + + Enter to create · Esc/Ctrl+C cancel + + + )} + + ); +} + +/** + * Creating app phase component. + */ +function CreatingAppPhase({ platform }: { platform: 'android' | 'ios' }): React.ReactElement { + const message = platform === 'android' ? 'Creating Android app...' : 'Creating iOS app...'; + + return ( + + + Firebase App Creation + + + + + + {message} + + + This may take a few seconds... + + + ); +} + +/** + * No projects found phase component - offers to create or link Firebase project. + */ +function NoProjectsPhase({ + onOpenConsole, + onSelectGcp, + onCancel, +}: { + onOpenConsole: () => void; + onSelectGcp: () => void; + onCancel: () => void; +}): React.ReactElement { + const items = [ + { + label: '🌐 Open Firebase Console', + value: 'console', + }, + { + label: '📦 Add Firebase to existing GCP project', + value: 'gcp', + }, + { + label: '← Back', + value: 'cancel', + }, + ]; + + useCancelInput(onCancel); + + const handleSelect = (item: { value: string }) => { + switch (item.value) { + case 'console': + onOpenConsole(); + break; + case 'gcp': + onSelectGcp(); + break; + case 'cancel': + onCancel(); + break; + } + }; + + return ( + + + + No Firebase Projects Found + + + + No Firebase projects are associated with this account. + + + Create a new project or add Firebase to an existing GCP project: + + + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +/** + * GCP project selector component. + */ +function GcpProjectSelector({ + projects, + onSelect, + onCancel, +}: { + projects: GcpProject[]; + onSelect: (project: GcpProject) => void; + onCancel: () => void; +}): React.ReactElement { + const items = projects.map((p) => ({ + label: p.name || p.projectId, + value: p, + })); + + useCancelInput(onCancel); + + return ( + + + Select GCP Project + + + Select a project to add Firebase: + + onSelect(item.value)} /> + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +/** + * Adding Firebase to GCP project phase component. + */ +function AddingFirebasePhase({ projectId }: { projectId: string }): React.ReactElement { + return ( + + + Adding Firebase + + + + + + Adding Firebase to {projectId}... + + + This may take a moment... + + + ); +} + +/** + * Service Account menu action type. + */ +type ServiceAccountMenuAction = + | { type: 'select_existing' } + | { type: 'create_new' } + | { type: 'paste_json' } + | { type: 'skip' }; + +/** + * Service Account menu phase component. + */ +function ServiceAccountMenuPhase({ + projectId, + onAction, + onCancel, +}: { + projectId: string; + onAction: (action: ServiceAccountMenuAction) => void; + onCancel: () => void; +}): React.ReactElement { + const items: Array<{ label: string; value: ServiceAccountMenuAction }> = [ + { + label: '➕ Create new Service Account', + value: { type: 'create_new' }, + }, + { + label: '📋 Select existing Service Account', + value: { type: 'select_existing' }, + }, + { + label: '📄 Paste Service Account JSON', + value: { type: 'paste_json' }, + }, + { + label: '⏭ Skip (configure later)', + value: { type: 'skip' }, + }, + ]; + + useCancelInput(onCancel); + + return ( + + + 🔑 Service Account Setup + + + Project: {projectId} + + + Service Account is required for server-side Firebase Admin SDK. + + onAction(item.value)} /> + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +/** + * Service Account selector component. + */ +function ServiceAccountSelector({ + accounts, + onSelect, + onCancel, +}: { + accounts: ServiceAccount[]; + onSelect: (account: ServiceAccount) => void; + onCancel: () => void; +}): React.ReactElement { + const items = accounts.map((a) => ({ + label: a.displayName || a.email, + value: a, + })); + + useCancelInput(onCancel); + + return ( + + + Select Service Account - + onSelect(item.value)} /> ↑↓ navigate · Enter select · Esc/Ctrl+C cancel @@ -411,37 +852,32 @@ function NoAppsFoundPhase({ } /** - * Create app input phase component. + * Create Service Account input phase component. */ -function CreateAppInputPhase({ - platform, +function CreateServiceAccountInputPhase({ + projectId, onSubmit, onCancel, }: { - platform: 'android' | 'ios'; - onSubmit: (identifier: string, displayName?: string) => void; + projectId: string; + onSubmit: (accountId: string, displayName?: string) => void; onCancel: () => void; }): React.ReactElement { - const [identifier, setIdentifier] = useState(''); + const [accountId, setAccountId] = useState('clix-firebase-admin'); const [displayName, setDisplayName] = useState(''); - const [stage, setStage] = useState<'identifier' | 'displayName'>('identifier'); - - const isAndroid = platform === 'android'; - const identifierLabel = isAndroid ? 'Package Name' : 'Bundle ID'; - const identifierPlaceholder = isAndroid ? 'com.example.app' : 'com.example.app'; - const title = isAndroid ? 'Create Android App' : 'Create iOS App'; + const [stage, setStage] = useState<'accountId' | 'displayName'>('accountId'); useCancelInput(onCancel); - const handleIdentifierSubmit = useCallback(() => { - if (identifier.trim()) { + const handleAccountIdSubmit = useCallback(() => { + if (accountId.trim()) { setStage('displayName'); } - }, [identifier]); + }, [accountId]); const handleDisplayNameSubmit = useCallback(() => { - onSubmit(identifier.trim(), displayName.trim() || undefined); - }, [identifier, displayName, onSubmit]); + onSubmit(accountId.trim(), displayName.trim() || undefined); + }, [accountId, displayName, onSubmit]); return ( - {title} + Create Service Account + + + Project: {projectId} - {stage === 'identifier' ? ( + {stage === 'accountId' ? ( <> - {identifierLabel}: (e.g., {identifierPlaceholder}) + Account ID: (e.g., clix-firebase-admin) {'> '} @@ -479,9 +918,7 @@ function CreateAppInputPhase({ ) : ( <> - - {identifierLabel}: {identifier} - + Account ID: {accountId} @@ -493,7 +930,7 @@ function CreateAppInputPhase({ @@ -507,11 +944,9 @@ function CreateAppInputPhase({ } /** - * Creating app phase component. + * Creating Service Account phase component. */ -function CreatingAppPhase({ platform }: { platform: 'android' | 'ios' }): React.ReactElement { - const message = platform === 'android' ? 'Creating Android app...' : 'Creating iOS app...'; - +function CreatingServiceAccountPhase(): React.ReactElement { return ( - Firebase App Creation + Service Account Creation - {message} + Creating service account and generating key... - This may take a few seconds... + This may take a moment... + + + ); +} + +/** + * Paste Service Account JSON phase component with real-time validation. + */ +function PasteServiceAccountPhase({ + onSubmit, + onCancel, +}: { + onSubmit: (json: ServiceAccountJson) => void; + onCancel: () => void; +}): React.ReactElement { + const [jsonInput, setJsonInput] = useState(''); + const [validation, setValidation] = useState(null); + const [quickValidation, setQuickValidation] = useState | null>(null); + + useCancelInput(onCancel); + + // Real-time validation + useEffect(() => { + if (!jsonInput.trim()) { + setValidation(null); + setQuickValidation(null); + return; + } + + // Quick validation for UI feedback + const quick = quickValidateServiceAccountJson(jsonInput); + setQuickValidation(quick); + + // Full validation if it looks like service account JSON + if (quick.isJson && quick.isServiceAccount) { + const result = parseServiceAccountJson(jsonInput); + setValidation(result); + } else { + setValidation(null); + } + }, [jsonInput]); + + const handleSubmit = useCallback(() => { + if (validation?.valid && validation.data) { + onSubmit(validation.data); + } + }, [validation, onSubmit]); + + return ( + + + Paste Service Account JSON + + + + Firebase Console → Project Settings → Service accounts → Generate new private key + + + + + {'> '} + + + + {/* Validation status display */} + {jsonInput.trim() && ( + + {quickValidation && ( + <> + + + {quickValidation.isJson ? '✓' : '✗'} JSON format + + + {quickValidation.isJson && ( + <> + + + {quickValidation.isServiceAccount ? '✓' : '✗'} Service Account type + + + + + {quickValidation.hasPrivateKey ? '✓' : '✗'} Private key present + + + {quickValidation.projectId && ( + + Project: {quickValidation.projectId} + + )} + + )} + + )} + + {/* Full validation errors */} + {validation && !validation.valid && ( + + Validation errors: + {validation.errors.map((err) => ( + + • {err} + + ))} + + )} + + {/* Success message */} + {validation?.valid && ( + + + ✓ Valid Service Account JSON - Press Enter to save + + + )} + + )} + + + {validation?.valid ? 'Enter to save · ' : ''}Esc/Ctrl+C cancel + + + ); +} + +/** + * Saving Service Account phase component. + */ +function SavingServiceAccountPhase(): React.ReactElement { + return ( + + + Saving Service Account + + + + + + Saving service account key... ); @@ -829,6 +1425,14 @@ export const FirebaseWizard: React.FC = ({ const [noAppsContext, setNoAppsContext] = useState(null); const [creatingAppPlatform, setCreatingAppPlatform] = useState<'android' | 'ios'>('android'); + // GCP project flow state (for adding Firebase to existing GCP project) + const [gcpProjects, setGcpProjects] = useState([]); + const [selectedGcpProject, setSelectedGcpProject] = useState(null); + + // Service Account flow state + const [serviceAccounts, setServiceAccounts] = useState([]); + const [, setServiceAccountJson] = useState(null); + const [service, setService] = useState(null); const [projectType, setProjectType] = useState(propProjectType ?? null); @@ -1111,8 +1715,8 @@ export const FirebaseWizard: React.FC = ({ // Fetch projects const fetchedProjects = await downloader.listProjects(); if (fetchedProjects.length === 0) { - setError('No Firebase projects found for this account.'); - setPhase('error'); + // No Firebase projects - show options to create or add Firebase to GCP project + setPhase('no_projects'); return; } @@ -1130,6 +1734,37 @@ export const FirebaseWizard: React.FC = ({ } }, [downloader]); + // Handler for Service Account setup (defined before handleAction to be used in it) + const handleServiceAccountSetup = useCallback(async () => { + // Get project ID from detection result + const projectId = getProjectIdFromResult(result); + if (!projectId) { + setError('No Firebase project configured. Please download config files first.'); + setPhase('error'); + return; + } + + setPhase('authenticating'); + + try { + // Ensure authenticated + const isAuth = await downloader.isAuthenticated(); + if (!isAuth) { + const authResult = await downloader.authenticate(openBrowser); + if (!authResult.success) { + setError(authResult.error || 'Authentication failed'); + setPhase('error'); + return; + } + } + + setPhase('service_account_menu'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Authentication failed'); + setPhase('error'); + } + }, [downloader, result]); + const handleAction = useCallback( async (action: CredentialAction) => { switch (action.type) { @@ -1180,9 +1815,14 @@ export const FirebaseWizard: React.FC = ({ detection: result, }); break; + + case 'setup_service_account': + // Start service account setup flow + await handleServiceAccountSetup(); + break; } }, - [service, handleSkip, handleDownload, onComplete, result], + [service, handleSkip, handleDownload, onComplete, result, handleServiceAccountSetup], ); const handleRetry = useCallback(async () => { @@ -1302,6 +1942,170 @@ export const FirebaseWizard: React.FC = ({ setPhase('select_project'); }, []); + // Handler for opening Firebase Console + const handleOpenFirebaseConsole = useCallback(() => { + openBrowser('https://console.firebase.google.com/'); + // Go back to menu after opening + setPhase('menu'); + }, []); + + // Handler for fetching GCP projects (to add Firebase) + const handleFetchGcpProjects = useCallback(async () => { + setPhase('authenticating'); + try { + const availableGcpProjects = await downloader.listAvailableGcpProjects(); + if (availableGcpProjects.length === 0) { + setError('No GCP projects available to add Firebase. Create a project first.'); + setPhase('error'); + return; + } + setGcpProjects(availableGcpProjects); + setPhase('select_gcp_project'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch GCP projects'); + setPhase('error'); + } + }, [downloader]); + + // Handler for GCP project selection + const handleGcpProjectSelect = useCallback( + async (project: GcpProject) => { + setSelectedGcpProject(project); + setPhase('adding_firebase'); + + try { + const firebaseProject = await downloader.addFirebaseToProject(project.projectId); + // Add to projects list and select it + setProjects([firebaseProject]); + projectSelectRef.current(firebaseProject); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add Firebase to project'); + setPhase('error'); + } + }, + [downloader], + ); + + // Handler for Service Account menu action + const handleServiceAccountMenuAction = useCallback( + async (action: ServiceAccountMenuAction) => { + const projectId = getProjectIdFromResult(result); + if (!projectId) { + setError('No project ID found'); + setPhase('error'); + return; + } + + switch (action.type) { + case 'select_existing': + setPhase('authenticating'); + try { + const accounts = await downloader.listServiceAccounts(projectId); + if (accounts.length === 0) { + setError('No service accounts found. Create a new one instead.'); + setPhase('service_account_menu'); + return; + } + setServiceAccounts(accounts); + setPhase('select_service_account'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch service accounts'); + setPhase('error'); + } + break; + + case 'create_new': + setPhase('create_service_account'); + break; + + case 'paste_json': + setPhase('paste_service_account'); + break; + + case 'skip': + setPhase('status'); + break; + } + }, + [downloader, result], + ); + + // Handler for selecting existing service account + const handleSelectServiceAccount = useCallback( + async (account: ServiceAccount) => { + const projectId = getProjectIdFromResult(result); + if (!projectId) { + setError('No project ID found'); + setPhase('error'); + return; + } + + setPhase('creating_service_account'); + + try { + const json = await downloader.downloadServiceAccountKey(projectId, account.email); + setServiceAccountJson(json); + await downloader.saveServiceAccountJson(projectPath, json); + setPhase('status'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate service account key'); + setPhase('error'); + } + }, + [downloader, result, projectPath], + ); + + // Handler for creating new service account + const handleCreateServiceAccount = useCallback( + async (accountId: string, displayName?: string) => { + const projectId = getProjectIdFromResult(result); + if (!projectId) { + setError('No project ID found'); + setPhase('error'); + return; + } + + setPhase('creating_service_account'); + + try { + const json = await downloader.createServiceAccountWithKey( + projectId, + accountId, + displayName, + ); + setServiceAccountJson(json); + await downloader.saveServiceAccountJson(projectPath, json); + setPhase('status'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create service account'); + setPhase('error'); + } + }, + [downloader, result, projectPath], + ); + + // Handler for pasting service account JSON + const handleSaveServiceAccountJson = useCallback( + async (json: ServiceAccountJson) => { + setPhase('saving_service_account'); + + try { + setServiceAccountJson(json); + await downloader.saveServiceAccountJson(projectPath, json); + setPhase('status'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save service account'); + setPhase('error'); + } + }, + [downloader, projectPath], + ); + + // Handler for going back to service account menu + const handleServiceAccountCancel = useCallback(() => { + setPhase('service_account_menu'); + }, []); + switch (phase) { case 'detecting': return ; @@ -1404,6 +2208,69 @@ export const FirebaseWizard: React.FC = ({ case 'creating_app': return ; + // New phases for no projects and service account + case 'no_projects': + return ( + + ); + + case 'select_gcp_project': + return ( + + ); + + case 'adding_firebase': + return ; + + case 'service_account_menu': + return ( + + ); + + case 'select_service_account': + return ( + + ); + + case 'create_service_account': + return ( + + ); + + case 'creating_service_account': + return ; + + case 'paste_service_account': + return ( + + ); + + case 'saving_service_account': + return ; + case 'error': return ( From 1c184418fcade6de038a5fe4c6c87f4d7cb55215 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Fri, 6 Feb 2026 14:21:38 +0900 Subject: [PATCH 02/30] refactor(firebase): replace REST API clients with googleapis library Replace direct Firebase Management and IAM REST API implementations with @googleapis/firebase and @googleapis/iam packages. This reduces code by 57% while maintaining full backward compatibility through existing public APIs. Key improvements: - Reduced firebase-api.ts from 366 to 223 lines (-39%) - Reduced iam-api.ts from 219 to 126 lines (-42%) - Removed duplicate pagination and token handling logic - Type safety improved with generated types from googleapis - Automatic token refresh via OAuth2Client - Cleaner error handling with googleapis's built-in support All 588 unit tests and 20 E2E tests pass. --- bun.lock | 49 +- package.json | 3 + src/cli.tsx | 49 -- src/commands/firebase.tsx | 46 -- src/commands/ios-setup/index.tsx | 525 ------------------ .../skill/__tests__/preparation.test.ts | 168 ++++++ src/commands/skill/index.tsx | 38 +- src/commands/skill/preparation.ts | 354 ++++++++++++ src/lib/__tests__/skills.test.ts | 170 ++++++ src/lib/commands/__tests__/registry.test.ts | 19 - src/lib/commands/firebase.tsx | 47 -- src/lib/commands/ios-setup.tsx | 40 -- src/lib/commands/registry.ts | 4 - src/lib/config/index.ts | 16 + src/lib/config/project-config-manager.ts | 59 +- src/lib/config/project-config-schema.ts | 138 ++++- src/lib/services/firebase/api/firebase-api.ts | 439 +++++---------- src/lib/services/firebase/api/iam-api.ts | 233 +++----- src/lib/services/firebase/api/index.ts | 10 - src/lib/services/firebase/api/types.ts | 348 ------------ src/lib/skills.ts | 71 +++ src/ui/components/InstallPreparationUI.tsx | 445 +++++++++++++++ 22 files changed, 1722 insertions(+), 1549 deletions(-) delete mode 100644 src/commands/firebase.tsx delete mode 100644 src/commands/ios-setup/index.tsx create mode 100644 src/commands/skill/__tests__/preparation.test.ts create mode 100644 src/commands/skill/preparation.ts delete mode 100644 src/lib/commands/firebase.tsx delete mode 100644 src/lib/commands/ios-setup.tsx create mode 100644 src/ui/components/InstallPreparationUI.tsx diff --git a/bun.lock b/bun.lock index b310a85..99b1788 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,9 @@ "@clix-so/clix-agent-skills": "^0.2.3", "@expo/apple-utils": "^2.1.14", "@expo/plist": "^0.4.8", + "@googleapis/cloudresourcemanager": "^6.0.1", + "@googleapis/firebase": "^12.0.1", + "@googleapis/iam": "^36.0.0", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", "ink": "^6.6.0", @@ -67,6 +70,12 @@ "@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=="], + "@googleapis/cloudresourcemanager": ["@googleapis/cloudresourcemanager@6.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-HO/1fwrqiE0msHxSXreM6ljCHuzBW+zEfKIKdzbcTsu+OBDJtWJxQ0flcD4kS2c4rVZ2W1j+RUQ1+eFquN56Mg=="], + + "@googleapis/firebase": ["@googleapis/firebase@12.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-ODFU7EbB2G/WFsXiNCXjRgJPRxT5JYh3ydznNdsVng/1SnWmQZK8Bgufvxq6ZWnNDqZJTtNUqAwjbMBoP6N3MA=="], + + "@googleapis/iam": ["@googleapis/iam@36.0.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-dPrhP3zswkP3+ADoJjuMQissm/4591545N+7nQ11YsEJKTvJCYnAfXjdkJ4QyThdLNIx8TnzRAU2PGW9Sv9NrA=="], + "@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=="], @@ -211,6 +220,10 @@ "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], "centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="], @@ -277,6 +290,8 @@ "dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], @@ -287,6 +302,12 @@ "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], @@ -339,6 +360,10 @@ "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -351,6 +376,10 @@ "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], @@ -361,6 +390,8 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ=="], @@ -471,6 +502,8 @@ "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "meow": ["meow@14.0.0", "", {}, "sha512-JhC3R1f6dbspVtmF3vKjAWz1EVIvwFrGGPLSdU6rK79xBwHWTuHoLnRX/t1/zHS1Ch1Y2UtIrih7DAHuH9JFJA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -507,6 +540,8 @@ "oauth-sign": ["oauth-sign@0.9.0", "", {}, "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -561,7 +596,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="], + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -599,6 +634,14 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-plist": ["simple-plist@1.4.0", "", { "dependencies": { "bplist-creator": "0.1.1", "bplist-parser": "0.3.2", "plist": "^3.0.5" } }, "sha512-Emr2CR0T6cfQlbXxk7KtpU183WpJXWdl9c7D8uTtduX7bzVO1A6yTO6BauGzbWQhdOfpggcc9s0PN8+JyG/2gQ=="], @@ -665,6 +708,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -763,6 +808,8 @@ "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "request/qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="], + "request/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="], "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], diff --git a/package.json b/package.json index 766bf24..f8f7ca4 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,9 @@ "@clix-so/clix-agent-skills": "^0.2.3", "@expo/apple-utils": "^2.1.14", "@expo/plist": "^0.4.8", + "@googleapis/cloudresourcemanager": "^6.0.1", + "@googleapis/firebase": "^12.0.1", + "@googleapis/iam": "^36.0.0", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", "ink": "^6.6.0", diff --git a/src/cli.tsx b/src/cli.tsx index 656f9fa..7170720 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -2,9 +2,7 @@ import meow from 'meow'; import { agentCommand } from './commands/agent'; import { chatCommand } from './commands/chat'; import { debugCommand } from './commands/debug'; -import { firebaseCommand } from './commands/firebase'; import { installMCPCommand } from './commands/install-mcp'; -import { runIosSetupCommand } from './commands/ios-setup/index'; import { loginCommand } from './commands/login'; import { logoutCommand } from './commands/logout'; import { resumeCommand } from './commands/resume'; @@ -45,7 +43,6 @@ function generateHelpText(): string { whoami Show current logged-in user agent [name] List or switch AI agents ${localSkillCommands} - firebase Check and configure Firebase credentials debug Interactive debugging assistant install-mcp [agent] Install Clix MCP Server resume Resume a previous session @@ -68,7 +65,6 @@ ${localSkillCommands} $ clix resume $ clix install $ clix doctor - $ clix firebase $ clix debug "Push notifications not working on iOS" $ clix install-mcp $ clix install-mcp claude @@ -104,26 +100,6 @@ 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', - }, }, }); @@ -203,10 +179,6 @@ async function main() { }); break; - case 'firebase': - await firebaseCommand(); - break; - case 'setup': { const status = await checkFirstRun(); if (status.needsSetup) { @@ -217,27 +189,6 @@ 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 runIosSetupCommand({ - 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/firebase.tsx b/src/commands/firebase.tsx deleted file mode 100644 index de3b99e..0000000 --- a/src/commands/firebase.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { FirebaseWizard } from '../ui/components/FirebaseWizard'; -import { printFinalOutput } from '../ui/utils/finalOutput'; -import { safeRender } from '../ui/utils/safeRender'; - -/** - * Firebase command - check and configure Firebase credentials - * - * Usage: clix firebase - */ -export async function firebaseCommand(): Promise { - const projectPath = process.cwd(); - - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - if (result.skipped) { - printFinalOutput({ - type: 'info', - title: 'Firebase setup', - message: 'Setup skipped', - }); - } else if (result.completed) { - printFinalOutput({ - type: 'success', - title: 'Firebase setup', - message: 'Configuration complete', - }); - } - resolve(); - }} - onCancel={() => { - unmount(); - printFinalOutput({ - type: 'info', - title: 'Firebase setup', - message: 'Setup cancelled', - }); - resolve(); - }} - />, - ); - }); -} diff --git a/src/commands/ios-setup/index.tsx b/src/commands/ios-setup/index.tsx deleted file mode 100644 index af8f5b4..0000000 --- a/src/commands/ios-setup/index.tsx +++ /dev/null @@ -1,525 +0,0 @@ -import { Box, Text, useInput } from 'ink'; -import Spinner from 'ink-spinner'; -import type React from 'react'; -import { - addClixToExtensionTarget, - addNotificationServiceExtension, - backupPodfile, - backupProject, - createExtensionFiles, - type ExtensionContext, - getExtensionBundleId, - getExtensionName, - hasPodfile, -} from '../../lib/ios'; -import type { PushSetupResult } from '../../lib/push'; -import { - type GuidedSetupContext, - type GuidedSetupResult, - GuidedSetupWizard, -} from '../../ui/components/GuidedSetupWizard'; -import { PushSetupWizard } from '../../ui/components/PushSetupWizard'; -import { type IosSetupOptions, type IosSetupResult, IosSetupUI } from '../../ui/IosSetupUI'; -import { type FinalOutputResult, printFinalOutput } from '../../ui/utils/finalOutput'; -import { safeRender } from '../../ui/utils/safeRender'; - -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'; -} - -/** - * Result of automated project modification phase. - */ -interface ProjectModificationResult { - success: boolean; - extensionFilesCreated: boolean; - pbxprojModified: boolean; - podfileModified: boolean; - createdFiles: string[]; - warnings: string[]; - /** If true, fall back to guided wizard for manual steps */ - requiresManualSteps: boolean; - error?: string; -} - -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. Proceeding to extension setup...' - : '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 } = safeRender( - { - unmount(); - resolve(result); - }} - />, - ); - }); -} - -/** - * UI component for showing project modification progress. - */ -function ProjectModificationUI({ - status, - warnings, -}: { - status: string; - warnings: string[]; -}): React.ReactElement { - return ( - - - - - - {status} - - {warnings.map((warning) => ( - - ⚠ {warning} - - ))} - - ); -} - -/** - * Print the result of project modification. - */ -function printModificationResult(result: ProjectModificationResult): void { - if (!result.extensionFilesCreated && !result.pbxprojModified && !result.podfileModified) { - return; - } - - console.log(''); - if (result.extensionFilesCreated) { - console.log('✓ Extension files created'); - for (const file of result.createdFiles) { - console.log(` • ${file}`); - } - } - if (result.pbxprojModified) { - console.log('✓ Xcode project updated (NSE target added)'); - } - if (result.podfileModified) { - console.log('✓ Podfile updated (extension target added)'); - console.log(' Run: cd ios && pod install'); - } - if (result.warnings.length > 0) { - console.log(''); - for (const warning of result.warnings) { - console.log(`⚠ ${warning}`); - } - } -} - -/** - * Execute the project modification steps. - */ -async function executeProjectModification( - directResult: IosSetupResult, - result: ProjectModificationResult, - updateStatus: (status: string) => void, - pushEnvironment?: 'development' | 'production', -): Promise { - const { agentContext } = directResult; - if (!agentContext) return; - - const extensionName = getExtensionName(agentContext.appName); - const extensionBundleId = getExtensionBundleId(agentContext.bundleId, agentContext.appName); - const extensionDir = `${agentContext.iosDir}/${extensionName}`; - - // 1. Create extension files - const extContext: ExtensionContext = { - appName: agentContext.appName, - bundleId: agentContext.bundleId, - iosDir: agentContext.iosDir, - pushEnvironment: pushEnvironment ?? 'development', - }; - - const extResult = await createExtensionFiles(extContext); - if (extResult.success) { - result.extensionFilesCreated = true; - result.createdFiles.push(...extResult.createdFiles); - } else { - result.warnings.push(extResult.error || 'Failed to create extension files'); - result.requiresManualSteps = true; - return; - } - - // 2. Modify pbxproj - updateStatus('Modifying Xcode project...'); - backupProject(agentContext.projectPath); - - const pbxResult = await addNotificationServiceExtension({ - projectPath: agentContext.projectPath, - extensionName, - extensionBundleId, - extensionDir, - appGroupId: agentContext.appGroupId, - teamId: directResult.projectInfo?.teamId, - deploymentTarget: '14.0', - }); - - if (pbxResult.success) { - result.pbxprojModified = pbxResult.targetAdded; - result.warnings.push(...pbxResult.warnings); - } else { - result.warnings.push(pbxResult.error || 'Failed to modify pbxproj'); - result.requiresManualSteps = true; - } - - // 3. Modify Podfile (if exists) - if (hasPodfile(agentContext.iosDir)) { - updateStatus('Updating Podfile...'); - backupPodfile(agentContext.iosDir); - - const podResult = await addClixToExtensionTarget({ - iosDir: agentContext.iosDir, - extensionName, - }); - - if (podResult.success) { - result.podfileModified = podResult.modified; - } else { - result.warnings.push(podResult.error || 'Failed to modify Podfile'); - } - } - - result.success = true; -} - -/** - * Run automated project modification phase. - * Creates extension files and modifies pbxproj/Podfile programmatically. - */ -async function runProjectModification( - directResult: IosSetupResult, - pushEnvironment?: 'development' | 'production', -): Promise { - const result: ProjectModificationResult = { - success: false, - extensionFilesCreated: false, - pbxprojModified: false, - podfileModified: false, - createdFiles: [], - warnings: [], - requiresManualSteps: false, - }; - - if (!directResult.agentContext) { - result.success = true; - return result; - } - - // Render progress UI - const displayWarnings: string[] = []; - let currentStatus = 'Creating extension files...'; - - const { unmount, rerender } = safeRender( - , - ); - - const updateStatus = (status: string) => { - currentStatus = status; - displayWarnings.push(...result.warnings.filter((w) => !displayWarnings.includes(w))); - rerender(); - }; - - try { - await executeProjectModification(directResult, result, updateStatus, pushEnvironment); - } catch (error) { - result.error = error instanceof Error ? error.message : String(error); - result.requiresManualSteps = true; - } finally { - unmount(); - } - - printModificationResult(result); - return result; -} - -/** - * Run the guided setup phase (Extension file generation + Xcode configuration guide) - * Used as fallback when automated modification fails or for manual verification. - */ -async function runGuidedSetup( - directResult: IosSetupResult, -): Promise { - if (!directResult.agentContext) { - return undefined; - } - - const context: GuidedSetupContext = { - bundleId: directResult.agentContext.bundleId, - appGroupId: directResult.agentContext.appGroupId, - appName: directResult.agentContext.appName, - iosDir: directResult.agentContext.iosDir, - entitlementsPath: directResult.agentContext.entitlementsPath, - }; - - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(result); - }} - />, - ); - }); -} - -/** - * Confirmation prompt component for push setup - */ -function PushSetupConfirmation({ - onYes, - onNo, -}: { - onYes: () => void; - onNo: () => void; -}): React.ReactElement { - useInput((input, key) => { - if (input.toLowerCase() === 'y' || key.return) { - onYes(); - } else if (input.toLowerCase() === 'n' || key.escape) { - onNo(); - } - }); - - return ( - - - ✓ iOS setup completed! - - - - Set up APNS key for Firebase push notifications? [Y/n] - - - - ); -} - -/** - * Ask user if they want to set up push notifications - */ -async function askPushSetupConfirmation(): Promise { - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(true); - }} - onNo={() => { - unmount(); - resolve(false); - }} - />, - ); - }); -} - -/** - * Run the push setup wizard (Phase 3) - */ -async function runPushSetup(directResult: IosSetupResult): Promise { - const projectPath = process.cwd(); - - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(result); - }} - onCancel={() => { - unmount(); - resolve(null); - }} - />, - ); - }); -} - -export async function runIosSetupCommand(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: Automated project modification (pbxproj + Podfile) - let modificationResult: ProjectModificationResult | undefined; - let guidedResult: GuidedSetupResult | undefined; - - if (directResult.agentContext) { - console.log('\n'); // Add spacing before modification phase - modificationResult = await runProjectModification(directResult, options.pushEnvironment); - - // Fall back to guided setup if automated modification failed or requires manual steps - if (modificationResult.requiresManualSteps) { - console.log('\n'); // Add spacing before guided setup - console.log('Some steps require manual configuration. Starting guided setup...'); - console.log(''); - guidedResult = await runGuidedSetup(directResult); - } - } - - // Phase 3: Push setup (optional - APNS key + Firebase) - // Only ask if Phase 1 & 2 were successful - const phase2Success = - modificationResult?.success && !modificationResult.requiresManualSteps - ? true - : (guidedResult?.success ?? true); - - if (directResult.success && phase2Success) { - console.log('\n'); // Add spacing before push setup prompt - const shouldSetupPush = await askPushSetupConfirmation(); - - if (shouldSetupPush) { - const pushResult = await runPushSetup(directResult); - printConsolidatedOutputWithModification( - directResult, - modificationResult, - guidedResult, - pushResult, - ); - } else { - printConsolidatedOutputWithModification(directResult, modificationResult, guidedResult, null); - } - } -} - -/** - * Print consolidated output including modification results. - */ -function printConsolidatedOutputWithModification( - directResult: IosSetupResult, - modificationResult: ProjectModificationResult | undefined, - guidedResult: GuidedSetupResult | undefined, - pushResult: PushSetupResult | null, -): void { - console.log('\n'); - console.log('═══════════════════════════════════════════════'); - console.log(' iOS Push Setup Complete! '); - console.log('═══════════════════════════════════════════════'); - console.log(''); - - // Phase 1 summary - if (directResult.success) { - console.log('✓ Capabilities configured'); - console.log('✓ Entitlements created'); - } - - // Phase 2 summary (modification or guided) - if (modificationResult?.extensionFilesCreated) { - console.log('✓ Extension files created'); - } - if (modificationResult?.pbxprojModified) { - console.log('✓ Xcode project updated'); - } - if (modificationResult?.podfileModified) { - console.log('✓ Podfile updated'); - } - if (guidedResult?.success) { - console.log('✓ Guided setup completed'); - } - - // Phase 3 summary - if (pushResult?.success) { - console.log('✓ APNS key registered with Firebase'); - if (pushResult.context?.pushKey) { - console.log(` Key ID: ${pushResult.context.pushKey.apnsKeyId}`); - console.log(` Team ID: ${pushResult.context.pushKey.teamId}`); - } - } else if (pushResult === null) { - console.log('○ APNS key setup skipped'); - } - - // Warnings - if (modificationResult?.warnings && modificationResult.warnings.length > 0) { - console.log(''); - console.log('Warnings:'); - for (const warning of modificationResult.warnings) { - console.log(` ⚠ ${warning}`); - } - } - - console.log(''); - console.log('Your iOS app is ready to receive push notifications!'); - console.log(''); -} diff --git a/src/commands/skill/__tests__/preparation.test.ts b/src/commands/skill/__tests__/preparation.test.ts new file mode 100644 index 0000000..b143976 --- /dev/null +++ b/src/commands/skill/__tests__/preparation.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { ProjectType } from '@/lib/config'; +import { checkFirebaseStatus, checkIosStatus } from '../preparation'; + +// Mock the dependencies +const mockFirebaseService = { + detect: mock(() => + Promise.resolve({ + platform: 'react-native', + android: { valid: true, content: { project_info: { project_id: 'test-project' } } }, + ios: { valid: true, content: { PROJECT_ID: 'test-project' } }, + configured: true, + issues: [], + projectPath: '/test', + }), + ), + getStatus: mock(() => + Promise.resolve({ + status: 'configured', + androidConfigured: true, + iosConfigured: true, + issueCount: 0, + errorCount: 0, + warningCount: 0, + }), + ), +}; + +mock.module('@/lib/services/firebase/firebase-service', () => ({ + FirebaseService: class { + detect = mockFirebaseService.detect; + getStatus = mockFirebaseService.getStatus; + }, +})); + +describe('preparation', () => { + beforeEach(() => { + mockFirebaseService.detect.mockClear(); + mockFirebaseService.getStatus.mockClear(); + }); + + describe('checkIosStatus', () => { + test('should return needed=false for Android-only projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'android' }; + const status = await checkIosStatus('/test', projectType); + + expect(status.needed).toBe(false); + expect(status.entitlementsConfigured).toBe(true); + expect(status.nseConfigured).toBe(true); + }); + + test('should return needed=true for iOS projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + const status = await checkIosStatus('/test', projectType); + + expect(status.needed).toBe(true); + expect(status.entitlementsConfigured).toBe(false); + expect(status.nseConfigured).toBe(false); + }); + + test('should return needed=true for cross-platform projects', async () => { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + const status = await checkIosStatus('/test', projectType); + + expect(status.needed).toBe(true); + }); + + test('should use existing setup status from config', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + const setup = { + ios: { + bundleId: 'com.test.app', + teamId: 'ABC123', + appGroupId: 'group.com.test.app', + entitlementsConfigured: true, + nseConfigured: true, + }, + }; + + const status = await checkIosStatus('/test', projectType, setup); + + expect(status.needed).toBe(true); + expect(status.bundleId).toBe('com.test.app'); + expect(status.teamId).toBe('ABC123'); + expect(status.appGroupId).toBe('group.com.test.app'); + expect(status.entitlementsConfigured).toBe(true); + expect(status.nseConfigured).toBe(true); + }); + }); + + describe('checkFirebaseStatus', () => { + test('should return needed=false for unknown target', async () => { + const projectType: ProjectType = { framework: 'unknown', target: 'unknown' }; + const status = await checkFirebaseStatus('/test', projectType); + + expect(status.needed).toBe(false); + expect(status.configured).toBe(true); + }); + + test('should use setup status from config if already configured', async () => { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + const setup = { + firebase: { + projectId: 'my-project', + androidConfigured: true, + iosConfigured: true, + }, + }; + + const status = await checkFirebaseStatus('/test', projectType, setup); + + expect(status.configured).toBe(true); + expect(status.projectId).toBe('my-project'); + expect(status.androidConfigured).toBe(true); + expect(status.iosConfigured).toBe(true); + }); + + test('should detect Firebase config when not in setup', async () => { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + + const status = await checkFirebaseStatus('/test', projectType); + + expect(status.needed).toBe(true); + expect(mockFirebaseService.detect).toHaveBeenCalled(); + expect(mockFirebaseService.getStatus).toHaveBeenCalled(); + }); + + test('should check only Android for Android-only projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'android' }; + + mockFirebaseService.getStatus.mockResolvedValueOnce({ + status: 'configured', + androidConfigured: true, + iosConfigured: false, + issueCount: 0, + errorCount: 0, + warningCount: 0, + }); + + const status = await checkFirebaseStatus('/test', projectType); + + expect(status.configured).toBe(true); + expect(status.androidConfigured).toBe(true); + // iOS not needed, so configured is true + expect(status.iosConfigured).toBe(true); + }); + + test('should check only iOS for iOS-only projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + + mockFirebaseService.getStatus.mockResolvedValueOnce({ + status: 'configured', + androidConfigured: false, + iosConfigured: true, + issueCount: 0, + errorCount: 0, + warningCount: 0, + }); + + const status = await checkFirebaseStatus('/test', projectType); + + expect(status.configured).toBe(true); + // Android not needed, so configured is true + expect(status.androidConfigured).toBe(true); + expect(status.iosConfigured).toBe(true); + }); + }); +}); diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 5bbd8bd..26d18aa 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -7,8 +7,10 @@ import { type SkillType, } from '../../lib/skills'; import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; +import { InstallPreparationUI } from '../../ui/components/InstallPreparationUI'; import { printFinalOutput } from '../../ui/utils/finalOutput'; import { safeRender } from '../../ui/utils/safeRender'; +import type { PreparationContext } from './preparation'; interface SkillCommandOptions { action?: string; @@ -54,6 +56,27 @@ Examples: `; } +/** + * Run install preparation UI and return the context. + */ +async function runInstallPreparation(projectPath: string): Promise { + return new Promise((resolve) => { + const { unmount } = safeRender( + { + unmount(); + resolve(context); + }} + onCancel={() => { + unmount(); + resolve(null); + }} + />, + ); + }); +} + export async function skillCommand(options: SkillCommandOptions): Promise { const { action, platform } = options; @@ -83,13 +106,26 @@ export async function skillCommand(options: SkillCommandOptions): Promise } const skillType = action as SkillType; + const projectPath = process.cwd(); + + // For install skill, run preparation first + let preparationContext: PreparationContext | undefined; + if (skillType === 'install') { + const context = await runInstallPreparation(projectPath); + if (!context) { + // User cancelled or config missing + return; + } + preparationContext = context; + } // Create execute function that wraps executeSkill async function* executeCommand(executor: AgentExecutor): AsyncGenerator { yield* executeSkill(skillType, executor, { - projectPath: process.cwd(), + projectPath, platform, oneShot: true, + preparationContext, }); } diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts new file mode 100644 index 0000000..6f4a7c3 --- /dev/null +++ b/src/commands/skill/preparation.ts @@ -0,0 +1,354 @@ +/** + * Install preparation module. + * + * Handles all preparation steps before the AI agent is invoked for SDK installation. + * Ensures config.jsonc is present and all required setup (Firebase, iOS) is complete. + * + * @module commands/skill/preparation + */ + +import { + getProjectConfigManager, + type ProjectConfigV2, + type ProjectType, + type SetupStatus, +} from '@/lib/config'; +import { FirebaseService } from '@/lib/services/firebase/firebase-service'; +import { detectProjectType } from '@/lib/services/project-detector'; + +/** + * Status of Firebase configuration. + */ +export interface FirebaseStatus { + /** Whether Firebase is configured */ + configured: boolean; + /** Whether Android config (google-services.json) exists and is valid */ + androidConfigured: boolean; + /** Whether iOS config (GoogleService-Info.plist) exists and is valid */ + iosConfigured: boolean; + /** Firebase project ID if detected */ + projectId?: string; + /** Whether Firebase setup is needed based on project type */ + needed: boolean; +} + +/** + * Status of iOS configuration. + */ +export interface IosStatus { + /** Whether iOS setup is needed based on project type */ + needed: boolean; + /** Bundle ID if detected */ + bundleId?: string; + /** Team ID if detected */ + teamId?: string; + /** App Group ID if configured */ + appGroupId?: string; + /** Whether entitlements are configured */ + entitlementsConfigured: boolean; + /** Whether NSE is configured */ + nseConfigured: boolean; +} + +/** + * Context passed to the install skill after preparation. + */ +export interface PreparationContext { + /** Project root path */ + projectPath: string; + /** Loaded and migrated config */ + config: ProjectConfigV2; + /** Detected or loaded project type */ + projectType: ProjectType; + /** Firebase configuration status */ + firebase: FirebaseStatus; + /** iOS configuration status */ + ios: IosStatus; + /** Whether all required preparations are complete */ + ready: boolean; + /** List of missing preparations */ + missing: string[]; +} + +/** + * Result of checking if project is linked (config exists). + */ +export interface ProjectLinkStatus { + /** Whether project is linked */ + linked: boolean; + /** Config if linked */ + config?: ProjectConfigV2; + /** Error message if not linked */ + error?: string; +} + +/** + * Check if the project is linked (config.jsonc exists). + * + * @param projectPath - Path to the project root + * @returns Link status with config if available + */ +export async function checkProjectLinked(projectPath?: string): Promise { + const manager = getProjectConfigManager(projectPath); + + try { + const config = await manager.loadV2(); + + if (!config) { + return { + linked: false, + error: 'Project not linked. Run "clix login" first.', + }; + } + + return { + linked: true, + config, + }; + } catch (error) { + return { + linked: false, + error: error instanceof Error ? error.message : 'Failed to load project config', + }; + } +} + +/** + * Ensure project type is detected. + * If not present in config, auto-detect and save it. + * + * @param config - Current config + * @param projectPath - Path to the project root + * @returns Updated config with project type + */ +export async function ensureProjectType( + config: ProjectConfigV2, + projectPath: string, +): Promise<{ config: ProjectConfigV2; projectType: ProjectType }> { + if (config.projectType) { + return { config, projectType: config.projectType }; + } + + // Auto-detect project type + const projectType = await detectProjectType(projectPath); + + // Save to config + const manager = getProjectConfigManager(projectPath); + const updatedConfig: ProjectConfigV2 = { + ...config, + projectType, + }; + await manager.save(updatedConfig); + + return { config: updatedConfig, projectType }; +} + +/** + * Check Firebase configuration status. + * + * @param projectPath - Path to the project root + * @param projectType - Detected project type + * @param setup - Current setup status from config + * @returns Firebase status + */ +export async function checkFirebaseStatus( + projectPath: string, + projectType: ProjectType, + setup?: SetupStatus, +): Promise { + // Determine if Firebase is needed based on project type + const needed = projectType.target !== 'unknown'; + + if (!needed) { + return { + configured: true, + androidConfigured: true, + iosConfigured: true, + needed: false, + }; + } + + // Check if already marked as configured in setup + if (setup?.firebase?.androidConfigured && setup?.firebase?.iosConfigured) { + return { + configured: true, + androidConfigured: true, + iosConfigured: true, + projectId: setup.firebase.projectId, + needed: true, + }; + } + + // Detect Firebase config files + const firebaseService = new FirebaseService(projectPath, projectType); + const detection = await firebaseService.detect(); + const status = await firebaseService.getStatus(); + + // Determine what's needed based on target platform + const needsAndroid = projectType.target === 'android' || projectType.target === 'ios-android'; + const needsIos = projectType.target === 'ios' || projectType.target === 'ios-android'; + + const androidConfigured = !needsAndroid || status.androidConfigured; + const iosConfigured = !needsIos || status.iosConfigured; + + // Extract project ID if available + let projectId: string | undefined; + if (detection.android?.content && 'project_info' in detection.android.content) { + projectId = detection.android.content.project_info?.project_id; + } else if (detection.ios?.content && 'PROJECT_ID' in detection.ios.content) { + projectId = detection.ios.content.PROJECT_ID; + } + + return { + configured: androidConfigured && iosConfigured, + androidConfigured, + iosConfigured, + projectId, + needed: true, + }; +} + +/** + * Check iOS configuration status. + * + * @param projectPath - Path to the project root + * @param projectType - Detected project type + * @param setup - Current setup status from config + * @returns iOS status + */ +export async function checkIosStatus( + _projectPath: string, + projectType: ProjectType, + setup?: SetupStatus, +): Promise { + // iOS setup is only needed for iOS or cross-platform targets + const needed = projectType.target === 'ios' || projectType.target === 'ios-android'; + + if (!needed) { + return { + needed: false, + entitlementsConfigured: true, + nseConfigured: true, + }; + } + + // Check setup status from config + if (setup?.ios) { + return { + needed: true, + bundleId: setup.ios.bundleId, + teamId: setup.ios.teamId, + appGroupId: setup.ios.appGroupId, + entitlementsConfigured: setup.ios.entitlementsConfigured, + nseConfigured: setup.ios.nseConfigured, + }; + } + + // No iOS setup recorded + return { + needed: true, + entitlementsConfigured: false, + nseConfigured: false, + }; +} + +/** + * Gather preparation context without running interactive setup. + * This checks what's configured and what's missing. + * + * @param projectPath - Path to the project root (defaults to cwd) + * @returns Preparation context with status information + */ +export async function gatherPreparationContext( + projectPath: string = process.cwd(), +): Promise { + // Step 1: Check if project is linked + const linkStatus = await checkProjectLinked(projectPath); + if (!linkStatus.linked || !linkStatus.config) { + return null; + } + + const config = linkStatus.config; + + // Step 2: Ensure project type is detected + const { config: updatedConfig, projectType } = await ensureProjectType(config, projectPath); + + // Step 3: Check Firebase status + const firebase = await checkFirebaseStatus(projectPath, projectType, updatedConfig.setup); + + // Step 4: Check iOS status + const ios = await checkIosStatus(projectPath, projectType, updatedConfig.setup); + + // Step 5: Determine what's missing + const missing: string[] = []; + + if (firebase.needed && !firebase.configured) { + if (!firebase.androidConfigured) { + missing.push('Firebase Android config (google-services.json)'); + } + if (!firebase.iosConfigured) { + missing.push('Firebase iOS config (GoogleService-Info.plist)'); + } + } + + if (ios.needed) { + if (!ios.entitlementsConfigured) { + missing.push('iOS entitlements'); + } + if (!ios.nseConfigured) { + missing.push('Notification Service Extension'); + } + } + + const ready = missing.length === 0; + + return { + projectPath, + config: updatedConfig, + projectType, + firebase, + ios, + ready, + missing, + }; +} + +/** + * Update config with setup status after preparation is complete. + * + * @param projectPath - Path to the project root + * @param firebase - Firebase status to save + * @param ios - iOS status to save + */ +export async function saveSetupStatus( + projectPath: string, + firebase: FirebaseStatus, + ios: IosStatus, +): Promise { + const manager = getProjectConfigManager(projectPath); + const now = new Date().toISOString(); + + const setupUpdate: SetupStatus = {}; + + if (firebase.needed) { + setupUpdate.firebase = { + projectId: firebase.projectId, + androidConfigured: firebase.androidConfigured, + iosConfigured: firebase.iosConfigured, + completedAt: firebase.configured ? now : undefined, + }; + } + + if (ios.needed) { + setupUpdate.ios = { + bundleId: ios.bundleId, + teamId: ios.teamId, + appGroupId: ios.appGroupId, + entitlementsConfigured: ios.entitlementsConfigured, + nseConfigured: ios.nseConfigured, + completedAt: ios.entitlementsConfigured && ios.nseConfigured ? now : undefined, + }; + } + + await manager.updateSetup(setupUpdate); +} diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index 28271b8..2d4eea7 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -267,6 +267,176 @@ describe('SkillOptions', () => { }); }); +describe('preparationContext in install skill', () => { + /** + * These tests verify that preparationContext is properly integrated into the install skill prompt. + * This covers the functionality that was previously in standalone firebase and ios-setup commands. + */ + + test('install skill should include pre-configured setup section when preparationContext is provided', async () => { + const { getSkillPrompt } = await import('../skills'); + const preparationContext = { + projectPath: '/test/project', + config: { + version: 2 as const, + member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, + organization: { id: 'org-1', name: 'Test Org' }, + project: { id: 'project-1', name: 'Test Project', publicKey: 'pk_test_123' }, + linkedAt: '2024-01-01T00:00:00Z', + }, + projectType: { framework: 'react-native' as const, target: 'ios-android' as const }, + firebase: { + needed: true, + configured: true, + androidConfigured: true, + iosConfigured: true, + projectId: 'my-firebase-project', + }, + ios: { + needed: true, + bundleId: 'com.test.app', + teamId: 'TEAM123', + appGroupId: 'group.com.test.app', + entitlementsConfigured: true, + nseConfigured: false, + }, + missing: ['Notification Service Extension'], + ready: false, + }; + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + preparationContext, + }); + + // Verify pre-configured setup section is included + expect(prompt).toContain('Pre-configured Setup'); + expect(prompt).toContain('Test Project'); + + // Verify Clix project info + expect(prompt).toContain('Clix Project'); + expect(prompt).toContain('project-1'); + expect(prompt).toContain('pk_test_123'); + + // Verify Firebase info + expect(prompt).toContain('Firebase'); + expect(prompt).toContain('my-firebase-project'); + expect(prompt).toContain('Android (google-services.json)'); + expect(prompt).toContain('iOS (GoogleService-Info.plist)'); + + // Verify iOS info + expect(prompt).toContain('iOS'); + expect(prompt).toContain('com.test.app'); + expect(prompt).toContain('TEAM123'); + expect(prompt).toContain('group.com.test.app'); + expect(prompt).toContain('Entitlements'); + expect(prompt).toContain('NSE'); + + // Verify missing items + expect(prompt).toContain('Missing Setup'); + expect(prompt).toContain('Notification Service Extension'); + }); + + test('install skill should not include iOS section when iOS is not needed', async () => { + const { getSkillPrompt } = await import('../skills'); + const preparationContext = { + projectPath: '/test/project', + config: { + version: 2 as const, + member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, + organization: { id: 'org-1', name: 'Test Org' }, + project: { id: 'project-1', name: 'Android Project' }, + linkedAt: '2024-01-01T00:00:00Z', + }, + projectType: { framework: 'native' as const, target: 'android' as const }, + firebase: { + needed: true, + configured: true, + androidConfigured: true, + iosConfigured: true, + projectId: 'android-project', + }, + ios: { + needed: false, + bundleId: undefined, + teamId: undefined, + appGroupId: undefined, + entitlementsConfigured: true, + nseConfigured: true, + }, + missing: [], + ready: true, + }; + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + preparationContext, + }); + + // Should have Firebase section + expect(prompt).toContain('Firebase'); + expect(prompt).toContain('android-project'); + + // Should NOT have iOS section (needed=false) + const iosHeaderMatch = prompt.match(/### iOS\n/); + expect(iosHeaderMatch).toBeNull(); + }); + + test('install skill should not include Firebase section when Firebase is not needed', async () => { + const { getSkillPrompt } = await import('../skills'); + const preparationContext = { + projectPath: '/test/project', + config: { + version: 2 as const, + member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, + organization: { id: 'org-1', name: 'Test Org' }, + project: { id: 'project-1', name: 'Unknown Project' }, + linkedAt: '2024-01-01T00:00:00Z', + }, + projectType: { framework: 'unknown' as const, target: 'unknown' as const }, + firebase: { + needed: false, + configured: true, + androidConfigured: true, + iosConfigured: true, + projectId: undefined, + }, + ios: { + needed: false, + bundleId: undefined, + teamId: undefined, + appGroupId: undefined, + entitlementsConfigured: true, + nseConfigured: true, + }, + missing: [], + ready: true, + }; + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + preparationContext, + }); + + // Should NOT have Firebase section (needed=false) + const firebaseHeaderMatch = prompt.match(/### Firebase\n/); + expect(firebaseHeaderMatch).toBeNull(); + }); + + test('install skill without preparationContext should work normally', async () => { + const { getSkillPrompt } = await import('../skills'); + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + }); + + // Should have basic info but no pre-configured setup section + expect(prompt).toContain('Project path: /test/project'); + // Pre-configured Setup section should not appear without context + expect(prompt).not.toContain('### Clix Project'); + }); +}); + describe('error handling', () => { describe('getSkillPrompt error messages', () => { test('should return clear error message when skills package is missing', async () => { diff --git a/src/lib/commands/__tests__/registry.test.ts b/src/lib/commands/__tests__/registry.test.ts index 46cd257..e823367 100644 --- a/src/lib/commands/__tests__/registry.test.ts +++ b/src/lib/commands/__tests__/registry.test.ts @@ -145,25 +145,6 @@ describe('Command Registry', () => { const transfer = getCommand('transfer'); expect(transfer?.aliases).toContain('t'); }); - - test('should have ios-setup command with aliases', () => { - const iosSetup = getCommand('ios-setup'); - expect(iosSetup).toBeDefined(); - expect(iosSetup?.name).toBe('ios-setup'); - expect(iosSetup?.aliases).toContain('capabilities'); - expect(iosSetup?.aliases).toContain('ios-capabilities'); - }); - - test('should find ios-setup by alias', () => { - const iosSetup = getCommand('capabilities'); - expect(iosSetup).toBeDefined(); - expect(iosSetup?.name).toBe('ios-setup'); - }); - - test('ios-setup should be local-jsx type not skill', () => { - const iosSetup = getCommand('ios-setup'); - expect(iosSetup?.type).toBe('local-jsx'); - }); }); describe('skill commands', () => { diff --git a/src/lib/commands/firebase.tsx b/src/lib/commands/firebase.tsx deleted file mode 100644 index af2d1f5..0000000 --- a/src/lib/commands/firebase.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Firebase command - check and configure Firebase credentials. - * - * @module commands/firebase - */ - -import type { ReactNode } from 'react'; -import { FirebaseWizard } from '@/ui/components/FirebaseWizard'; -import type { Command, CommandDoneCallback } from './types'; - -/** - * Firebase command implementation. - * Opens the Firebase configuration wizard for detecting and validating credentials. - */ -export const firebaseCommand: Command = { - type: 'local-jsx', - name: 'firebase', - description: 'Check and configure Firebase credentials', - isEnabled: true, - isHidden: false, - - userFacingName() { - return '/firebase'; - }, - - async call(onDone: CommandDoneCallback): Promise { - const projectPath = process.cwd(); - - return ( - { - if (result.skipped) { - onDone('Firebase setup skipped'); - } else if (result.completed) { - onDone('Firebase configuration complete'); - } else { - onDone(); - } - }} - onCancel={() => { - onDone('Firebase setup cancelled'); - }} - /> - ); - }, -}; diff --git a/src/lib/commands/ios-setup.tsx b/src/lib/commands/ios-setup.tsx deleted file mode 100644 index ef72cb4..0000000 --- a/src/lib/commands/ios-setup.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * iOS Setup command - Configure iOS capabilities, NSE, and APNS key. - * - * @module commands/ios-setup - */ - -import type { ReactNode } from 'react'; -import { runIosSetupCommand } from '@/commands/ios-setup/index'; -import type { Command, CommandDoneCallback } from './types'; - -/** - * iOS Setup command for chat mode. - * Delegates to the CLI implementation. - */ -export const iosSetupCommand: Command = { - type: 'local-jsx', - name: 'ios-setup', - description: 'Configure iOS capabilities, NSE, and APNS key for push notifications', - aliases: ['capabilities', 'ios-capabilities'], - isEnabled: true, - isHidden: false, - - userFacingName() { - return '/ios-setup'; - }, - - async call(onDone: CommandDoneCallback): Promise { - try { - // Run the iOS setup command (skipPortal=true by default for chat mode) - await runIosSetupCommand({ - skipPortal: true, - }); - onDone?.('iOS setup complete'); - } catch (error) { - const message = error instanceof Error ? error.message : 'iOS setup failed'; - onDone?.(message); - } - return null; - }, -}; diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts index 0f50d11..ee5415a 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -9,10 +9,8 @@ import { agentCommand } from './agent'; import { compactCommand } from './compact'; import { debugCommand } from './debug'; import { exitCommand } from './exit'; -import { firebaseCommand } from './firebase'; import { helpCommand } from './help'; import { installMcpCommand } from './install-mcp'; -import { iosSetupCommand } from './ios-setup'; import { loginCommand } from './login'; import { logoutCommand } from './logout'; import { newCommand } from './new'; @@ -34,8 +32,6 @@ const BUILT_IN_COMMANDS: Command[] = [ compactCommand, agentCommand, debugCommand, - firebaseCommand, - iosSetupCommand, transferCommand, resumeCommand, installMcpCommand, diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index b72f115..8f3c1ff 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -8,11 +8,25 @@ export { ConfigManager, getConfigManager, resetConfigManager } from './manager'; // Project-local configuration export { getProjectConfigManager, ProjectConfigManager } from './project-config-manager'; export { + type ApnsSetup, + ApnsSetupSchema, CURRENT_PROJECT_CONFIG_VERSION, + ensureLatestVersion, + type FirebaseSetup, + FirebaseSetupSchema, + type IosSetup, + IosSetupSchema, + isConfigV1, + isConfigV2, + migrateV1ToV2, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, type ProjectConfig, ProjectConfigSchema, + type ProjectConfigV1, + ProjectConfigV1Schema, + type ProjectConfigV2, + ProjectConfigV2Schema, type ProjectFramework, type ProjectInfo, ProjectInfoSchema, @@ -23,6 +37,8 @@ export { type ProjectTargetPlatform, type ProjectType, ProjectTypeSchema, + type SetupStatus, + SetupStatusSchema, safeValidateProjectConfig, validateProjectConfig, } from './project-config-schema'; diff --git a/src/lib/config/project-config-manager.ts b/src/lib/config/project-config-manager.ts index 4273c46..cc9b480 100644 --- a/src/lib/config/project-config-manager.ts +++ b/src/lib/config/project-config-manager.ts @@ -2,9 +2,14 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { ConfigError, ERROR_CODES } from '../errors/types'; import { + CURRENT_PROJECT_CONFIG_VERSION, + ensureLatestVersion, + isConfigV2, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, type ProjectConfig, + type ProjectConfigV2, + type SetupStatus, safeValidateProjectConfig, } from './project-config-schema'; @@ -99,6 +104,7 @@ export class ProjectConfigManager { /** * Load project configuration from disk. * Returns null if config doesn't exist. + * Automatically migrates older versions to latest. * * @returns ProjectConfig or null if not found */ @@ -126,8 +132,16 @@ export class ProjectConfigManager { ); } - this.cachedConfig = validatedConfig; - return validatedConfig; + // Migrate to latest version if needed + const migratedConfig = ensureLatestVersion(validatedConfig); + + // Save migrated config if version changed + if (validatedConfig.version !== CURRENT_PROJECT_CONFIG_VERSION) { + await this.save(migratedConfig); + } + + this.cachedConfig = migratedConfig; + return migratedConfig; } catch (error) { // File doesn't exist - return null if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { @@ -152,6 +166,47 @@ export class ProjectConfigManager { } } + /** + * Load config and ensure it's at latest version (v2). + * Returns null if config doesn't exist. + * + * @returns ProjectConfigV2 or null if not found + */ + async loadV2(): Promise { + const config = await this.load(); + if (!config) { + return null; + } + return isConfigV2(config) ? config : ensureLatestVersion(config); + } + + /** + * Update the setup status in config. + * Creates setup object if it doesn't exist. + * + * @param updates - Partial setup status to merge + */ + async updateSetup(updates: Partial): Promise { + const config = await this.loadV2(); + if (!config) { + throw new ConfigError( + 'Project config not found. Run "clix login" first.', + ERROR_CODES.PROJECT_CONFIG_NOT_FOUND, + this.configFilePath, + ); + } + + const updatedConfig: ProjectConfigV2 = { + ...config, + setup: { + ...config.setup, + ...updates, + }, + }; + + await this.save(updatedConfig); + } + /** * Save project configuration to disk. * Creates the .clix directory if it doesn't exist. diff --git a/src/lib/config/project-config-schema.ts b/src/lib/config/project-config-schema.ts index cec3823..9eb445b 100644 --- a/src/lib/config/project-config-schema.ts +++ b/src/lib/config/project-config-schema.ts @@ -57,10 +57,68 @@ export const ProjectInfoSchema = z.object({ }); /** - * Main project configuration schema. + * Schema for iOS setup status. + */ +export const IosSetupSchema = z.object({ + /** iOS Bundle ID */ + bundleId: z.string().optional(), + /** Apple Team ID */ + teamId: z.string().optional(), + /** App Group ID for sharing data between app and extensions */ + appGroupId: z.string().optional(), + /** Whether entitlements have been configured */ + entitlementsConfigured: z.boolean(), + /** Whether Notification Service Extension has been configured */ + nseConfigured: z.boolean(), + /** ISO timestamp when iOS setup was completed */ + completedAt: z.string().datetime().optional(), +}); + +/** + * Schema for Firebase setup status. + */ +export const FirebaseSetupSchema = z.object({ + /** Firebase project ID */ + projectId: z.string().optional(), + /** Whether Android config (google-services.json) is configured */ + androidConfigured: z.boolean(), + /** Whether iOS config (GoogleService-Info.plist) is configured */ + iosConfigured: z.boolean(), + /** ISO timestamp when Firebase setup was completed */ + completedAt: z.string().datetime().optional(), +}); + +/** + * Schema for APNS setup status. + */ +export const ApnsSetupSchema = z.object({ + /** APNS Key ID */ + keyId: z.string().optional(), + /** Apple Team ID for APNS */ + teamId: z.string().optional(), + /** Whether APNS key has been registered with Firebase */ + registeredWithFirebase: z.boolean(), + /** ISO timestamp when APNS setup was completed */ + completedAt: z.string().datetime().optional(), +}); + +/** + * Schema for setup status tracking. + */ +export const SetupStatusSchema = z.object({ + /** iOS setup status */ + ios: IosSetupSchema.optional(), + /** Firebase setup status */ + firebase: FirebaseSetupSchema.optional(), + /** APNS setup status */ + apns: ApnsSetupSchema.optional(), +}); + +/** + * Main project configuration schema (version 1 - legacy). * Stored in .clix/config.jsonc in the project root. */ -export const ProjectConfigSchema = z.object({ +export const ProjectConfigV1Schema = z.object({ /** Configuration schema version */ version: z.literal(1), /** Logged-in member information */ @@ -75,18 +133,50 @@ export const ProjectConfigSchema = z.object({ linkedAt: z.string().datetime(), }); +/** + * Main project configuration schema (version 2 - current). + * Stored in .clix/config.jsonc in the project root. + */ +export const ProjectConfigV2Schema = z.object({ + /** Configuration schema version */ + version: z.literal(2), + /** Logged-in member information */ + member: ProjectMemberSchema, + /** Selected organization */ + organization: ProjectOrganizationSchema, + /** Selected project */ + project: ProjectInfoSchema, + /** Detected project type (framework + target platform) */ + projectType: ProjectTypeSchema.optional(), + /** ISO timestamp when this config was created/linked */ + linkedAt: z.string().datetime(), + /** Setup status tracking for install preparation */ + setup: SetupStatusSchema.optional(), +}); + +/** + * Union schema that accepts both v1 and v2 configs. + */ +export const ProjectConfigSchema = z.union([ProjectConfigV1Schema, ProjectConfigV2Schema]); + /** * Type definitions derived from schemas. */ export type ProjectMember = z.infer; export type ProjectOrganization = z.infer; export type ProjectInfo = z.infer; +export type IosSetup = z.infer; +export type FirebaseSetup = z.infer; +export type ApnsSetup = z.infer; +export type SetupStatus = z.infer; +export type ProjectConfigV1 = z.infer; +export type ProjectConfigV2 = z.infer; export type ProjectConfig = z.infer; /** * Current project config version. */ -export const CURRENT_PROJECT_CONFIG_VERSION = 1; +export const CURRENT_PROJECT_CONFIG_VERSION = 2; /** * Project config file name. @@ -120,3 +210,45 @@ export function safeValidateProjectConfig(data: unknown): ProjectConfig | null { const result = ProjectConfigSchema.safeParse(data); return result.success ? result.data : null; } + +/** + * Check if config is version 1. + */ +export function isConfigV1(config: ProjectConfig): config is ProjectConfigV1 { + return config.version === 1; +} + +/** + * Check if config is version 2. + */ +export function isConfigV2(config: ProjectConfig): config is ProjectConfigV2 { + return config.version === 2; +} + +/** + * Migrate v1 config to v2. + * + * @param config - v1 config to migrate + * @returns Migrated v2 config + */ +export function migrateV1ToV2(config: ProjectConfigV1): ProjectConfigV2 { + return { + ...config, + version: 2, + setup: undefined, + }; +} + +/** + * Ensure config is at the latest version (v2). + * Migrates older versions if necessary. + * + * @param config - Config of any supported version + * @returns Config at latest version (v2) + */ +export function ensureLatestVersion(config: ProjectConfig): ProjectConfigV2 { + if (isConfigV2(config)) { + return config; + } + return migrateV1ToV2(config); +} diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index 5273159..98aceac 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -1,366 +1,223 @@ /** - * Firebase Management REST API client. + * Firebase Management API client using @googleapis/firebase. * * @module services/firebase/api/firebase-api */ +import { + cloudresourcemanager, + type cloudresourcemanager_v1, +} from '@googleapis/cloudresourcemanager'; +import { firebase, type firebase_v1beta1 } from '@googleapis/firebase'; +import { OAuth2Client } from 'google-auth-library'; import type { AndroidApp, - AppConfigResponse, CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, GcpProject, IosApp, - ListAndroidAppsResponse, - ListGcpProjectsResponse, - ListIosAppsResponse, - ListProjectsResponse, - Operation, } from './types'; -const BASE_URL = 'https://firebase.googleapis.com/v1beta1'; -const RESOURCE_MANAGER_URL = 'https://cloudresourcemanager.googleapis.com/v1'; +type FirebaseApi = firebase_v1beta1.Firebase; +type ResourceManagerApi = cloudresourcemanager_v1.Cloudresourcemanager; -/** - * Firebase Management API client. - * - * Uses the Firebase Management REST API to list projects, apps, and download configs. - */ export class FirebaseApiClient { - private getAccessToken: () => Promise; + private fb: FirebaseApi; + private rm: ResourceManagerApi; - /** - * Create a new Firebase API client. - * - * @param getAccessToken - Function to get a valid access token - */ constructor(getAccessToken: () => Promise) { - this.getAccessToken = getAccessToken; - } - - /** - * Make an authenticated GET request to the Firebase API. - */ - private async request(path: string): Promise { - const token = await this.getAccessToken(); - const url = `${BASE_URL}${path}`; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Firebase API error (${response.status}): ${error}`); - } - - return response.json() as Promise; - } + const auth = new OAuth2Client(); + auth.getAccessToken = async () => ({ token: await getAccessToken() }); - /** - * Make an authenticated POST request to the Firebase API. - */ - private async postRequest(path: string, body: unknown): Promise { - const token = await this.getAccessToken(); - const url = `${BASE_URL}${path}`; - - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Firebase API error (${response.status}): ${error}`); - } - - return response.json() as Promise; + this.fb = firebase({ version: 'v1beta1', auth }); + this.rm = cloudresourcemanager({ version: 'v1', auth }); } - /** - * Wait for a long-running operation to complete. - * - * @param operationName - Operation name from the initial response - * @param maxWaitMs - Maximum time to wait in milliseconds (default: 60s) - * @returns The completed operation result - */ - private async waitForOperation(operationName: string, maxWaitMs = 60000): Promise { - const startTime = Date.now(); - const pollIntervalMs = 1000; - - while (Date.now() - startTime < maxWaitMs) { - const operation = await this.request>(`/${operationName}`.replace(/^\/+/, '/')); + async listProjects(): Promise { + const projects: FirebaseProject[] = []; + let pageToken: string | undefined; - if (operation.done) { - if (operation.error) { - throw new Error(`Operation failed: ${operation.error.message}`); - } - if (!operation.response) { - throw new Error('Operation completed but no response returned'); - } - return operation.response; + do { + const res = await this.fb.projects.list({ pageToken }); + for (const p of res.data.results ?? []) { + projects.push({ + name: p.name ?? '', + projectId: p.projectId ?? '', + projectNumber: p.projectNumber ?? '', + displayName: p.displayName ?? '', + state: (p.state as 'ACTIVE' | 'DELETED') ?? 'ACTIVE', + }); } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - // Wait before polling again - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - - throw new Error(`Operation timed out after ${maxWaitMs}ms`); + return projects; } - /** - * Fetch paginated results from the Firebase API. - * - * @param basePath - Base path for the API endpoint - * @param extractor - Function to extract items from response - * @returns All items across all pages - */ - private async fetchPaginated( - basePath: string, - extractor: (response: R) => T[] | undefined, - ): Promise { - const items: T[] = []; + async listAndroidApps(projectId: string): Promise { + const apps: AndroidApp[] = []; let pageToken: string | undefined; do { - const params = new URLSearchParams(); - if (pageToken) { - params.set('pageToken', pageToken); - } - - const query = params.toString(); - const path = query ? `${basePath}?${query}` : basePath; - const response = await this.request(path); - - const extracted = extractor(response); - if (extracted) { - items.push(...extracted); + const res = await this.fb.projects.androidApps.list({ + parent: `projects/${projectId}`, + pageToken, + }); + for (const a of res.data.apps ?? []) { + apps.push({ + name: a.name ?? '', + appId: a.appId ?? '', + displayName: a.displayName ?? undefined, + packageName: a.packageName ?? '', + projectId: a.projectId ?? projectId, + }); } - pageToken = response.nextPageToken; + pageToken = res.data.nextPageToken ?? undefined; } while (pageToken); - return items; + return apps; } - /** - * Fetch config file contents from the Firebase API. - * - * @param path - API path for the config endpoint - * @returns Config file contents as string - */ - private async fetchConfig(path: string): Promise { - const response = await this.request(path); - return Buffer.from(response.configFileContents, 'base64').toString('utf-8'); - } - - /** - * Create an app and wait for the operation to complete. - * - * @param path - API path for the app creation endpoint - * @param request - App creation request - * @returns Created app - */ - private async createAppWithOperation(path: string, request: unknown): Promise { - const operation = await this.postRequest>(path, request); - - // If operation is already done, return the result - if (operation.done && operation.response) { - return operation.response; - } - - // Otherwise, wait for the operation to complete - return this.waitForOperation(operation.name); - } - - /** - * List all Firebase projects accessible to the user. - * - * @returns List of Firebase projects - */ - async listProjects(): Promise { - return this.fetchPaginated( - '/projects', - (response) => response.results, - ); - } + async listIosApps(projectId: string): Promise { + const apps: IosApp[] = []; + let pageToken: string | undefined; - /** - * List Android apps in a Firebase project. - * - * @param projectId - Firebase project ID - * @returns List of Android apps - */ - async listAndroidApps(projectId: string): Promise { - return this.fetchPaginated( - `/projects/${projectId}/androidApps`, - (response) => response.apps, - ); - } + do { + const res = await this.fb.projects.iosApps.list({ + parent: `projects/${projectId}`, + pageToken, + }); + for (const a of res.data.apps ?? []) { + apps.push({ + name: a.name ?? '', + appId: a.appId ?? '', + displayName: a.displayName ?? undefined, + bundleId: a.bundleId ?? '', + projectId: a.projectId ?? projectId, + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - /** - * List iOS apps in a Firebase project. - * - * @param projectId - Firebase project ID - * @returns List of iOS apps - */ - async listIosApps(projectId: string): Promise { - return this.fetchPaginated( - `/projects/${projectId}/iosApps`, - (response) => response.apps, - ); + return apps; } - /** - * Get Android app config (google-services.json). - * - * @param projectId - Firebase project ID - * @param appId - Android app ID - * @returns Config file contents as string - */ async getAndroidConfig(projectId: string, appId: string): Promise { - return this.fetchConfig(`/projects/${projectId}/androidApps/${appId}/config`); + const res = await this.fb.projects.androidApps.getConfig({ + name: `projects/${projectId}/androidApps/${appId}/config`, + }); + return Buffer.from(res.data.configFileContents ?? '', 'base64').toString('utf-8'); } - /** - * Get iOS app config (GoogleService-Info.plist). - * - * @param projectId - Firebase project ID - * @param appId - iOS app ID - * @returns Config file contents as string - */ async getIosConfig(projectId: string, appId: string): Promise { - return this.fetchConfig(`/projects/${projectId}/iosApps/${appId}/config`); + const res = await this.fb.projects.iosApps.getConfig({ + name: `projects/${projectId}/iosApps/${appId}/config`, + }); + return Buffer.from(res.data.configFileContents ?? '', 'base64').toString('utf-8'); } - /** - * Create a new Android app in a Firebase project. - * - * @param projectId - Firebase project ID - * @param request - App creation request with packageName and optional displayName - * @returns Created Android app - */ async createAndroidApp(projectId: string, request: CreateAndroidAppRequest): Promise { - return this.createAppWithOperation(`/projects/${projectId}/androidApps`, request); - } + const res = await this.fb.projects.androidApps.create({ + parent: `projects/${projectId}`, + requestBody: request, + }); - /** - * Create a new iOS app in a Firebase project. - * - * @param projectId - Firebase project ID - * @param request - App creation request with bundleId and optional displayName - * @returns Created iOS app - */ - async createIosApp(projectId: string, request: CreateIosAppRequest): Promise { - return this.createAppWithOperation(`/projects/${projectId}/iosApps`, request); + if (!res.data.name) { + throw new Error('Failed to create Android app: no operation name returned'); + } + const app = await this.waitForOperation(res.data.name); + return { + name: app.name ?? '', + appId: app.appId ?? '', + displayName: app.displayName ?? undefined, + packageName: app.packageName ?? '', + projectId: app.projectId ?? projectId, + }; } - // ============================================================================ - // GCP Project and Firebase Project Creation Methods - // ============================================================================ - - /** - * Make an authenticated GET request to an external URL. - */ - private async requestUrl(url: string): Promise { - const token = await this.getAccessToken(); - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, + async createIosApp(projectId: string, request: CreateIosAppRequest): Promise { + const res = await this.fb.projects.iosApps.create({ + parent: `projects/${projectId}`, + requestBody: request, }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`API error (${response.status}): ${error}`); + if (!res.data.name) { + throw new Error('Failed to create iOS app: no operation name returned'); } - - return response.json() as Promise; + const app = await this.waitForOperation(res.data.name); + return { + name: app.name ?? '', + appId: app.appId ?? '', + displayName: app.displayName ?? undefined, + bundleId: app.bundleId ?? '', + projectId: app.projectId ?? projectId, + }; } - /** - * Fetch paginated results from an external URL. - */ - private async fetchPaginatedUrl( - baseUrl: string, - extractor: (response: R) => T[] | undefined, - ): Promise { - const items: T[] = []; + async listGcpProjects(): Promise { + const projects: GcpProject[] = []; let pageToken: string | undefined; do { - const url = new URL(baseUrl); - if (pageToken) { - url.searchParams.set('pageToken', pageToken); + const res = await this.rm.projects.list({ pageToken }); + for (const p of res.data.projects ?? []) { + projects.push({ + projectId: p.projectId ?? '', + name: p.name ?? '', + projectNumber: p.projectNumber ?? '', + lifecycleState: (p.lifecycleState as GcpProject['lifecycleState']) ?? 'ACTIVE', + createTime: p.createTime ?? undefined, + }); } - - const response = await this.requestUrl(url.toString()); - - const extracted = extractor(response); - if (extracted) { - items.push(...extracted); - } - pageToken = response.nextPageToken; + pageToken = res.data.nextPageToken ?? undefined; } while (pageToken); - return items; - } - - /** - * List GCP projects that the user has access to. - * - * This can be used to find projects that don't have Firebase yet, - * so Firebase can be added to them. - * - * @returns List of GCP projects - */ - async listGcpProjects(): Promise { - return this.fetchPaginatedUrl( - `${RESOURCE_MANAGER_URL}/projects`, - (response) => response.projects, - ); + return projects; } - /** - * List GCP projects that don't have Firebase yet. - * - * Compares GCP projects with Firebase projects to find ones - * that can have Firebase added. - * - * @returns List of GCP projects without Firebase - */ async listAvailableGcpProjects(): Promise { const [gcpProjects, firebaseProjects] = await Promise.all([ this.listGcpProjects(), this.listProjects(), ]); - const firebaseProjectIds = new Set(firebaseProjects.map((p) => p.projectId)); - return gcpProjects.filter( (gcp) => gcp.lifecycleState === 'ACTIVE' && !firebaseProjectIds.has(gcp.projectId), ); } - /** - * Add Firebase to an existing GCP project. - * - * This creates a Firebase project linked to the GCP project. - * The project must not already have Firebase. - * - * @param projectId - GCP project ID - * @returns Created Firebase project - */ async addFirebaseToProject(projectId: string): Promise { - return this.createAppWithOperation(`/projects/${projectId}:addFirebase`, {}); + const res = await this.fb.projects.addFirebase({ project: `projects/${projectId}` }); + if (!res.data.name) { + throw new Error('Failed to add Firebase: no operation name returned'); + } + const project = await this.waitForOperation( + res.data.name, + ); + return { + name: project.name ?? '', + projectId: project.projectId ?? '', + projectNumber: project.projectNumber ?? '', + displayName: project.displayName ?? '', + state: (project.state as 'ACTIVE' | 'DELETED') ?? 'ACTIVE', + }; + } + + private async waitForOperation(operationName: string, maxWaitMs = 60000): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + const res = await this.fb.operations.get({ name: operationName }); + if (res.data.done) { + if (res.data.error) { + throw new Error(`Operation failed: ${res.data.error.message}`); + } + return res.data.response as T; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error(`Operation timed out after ${maxWaitMs}ms`); } } diff --git a/src/lib/services/firebase/api/iam-api.ts b/src/lib/services/firebase/api/iam-api.ts index b6e2c74..285fb69 100644 --- a/src/lib/services/firebase/api/iam-api.ts +++ b/src/lib/services/firebase/api/iam-api.ts @@ -1,205 +1,114 @@ /** - * Google IAM REST API client. - * - * Manages service accounts and keys for Firebase projects. + * Google IAM API client using @googleapis/iam. * * @module services/firebase/api/iam-api */ -import type { - CreateServiceAccountKeyRequest, - CreateServiceAccountRequest, - ListServiceAccountsResponse, - ServiceAccount, - ServiceAccountKey, -} from './types'; +import { iam, type iam_v1 } from '@googleapis/iam'; +import { OAuth2Client } from 'google-auth-library'; +import type { ServiceAccount, ServiceAccountKey } from './types'; -const IAM_BASE_URL = 'https://iam.googleapis.com/v1'; +type IamApi = iam_v1.Iam; -/** - * Google IAM API client. - * - * Uses the IAM REST API to manage service accounts and their keys. - */ export class IamApiClient { - private getAccessToken: () => Promise; + private api: IamApi; - /** - * Create a new IAM API client. - * - * @param getAccessToken - Function to get a valid access token - */ constructor(getAccessToken: () => Promise) { - this.getAccessToken = getAccessToken; - } - - /** - * Make an authenticated GET request to the IAM API. - */ - private async request(path: string): Promise { - const token = await this.getAccessToken(); - const url = `${IAM_BASE_URL}${path}`; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`IAM API error (${response.status}): ${error}`); - } + const auth = new OAuth2Client(); + auth.getAccessToken = async () => ({ token: await getAccessToken() }); - return response.json() as Promise; + this.api = iam({ version: 'v1', auth }); } - /** - * Make an authenticated POST request to the IAM API. - */ - private async postRequest(path: string, body: unknown): Promise { - const token = await this.getAccessToken(); - const url = `${IAM_BASE_URL}${path}`; - - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`IAM API error (${response.status}): ${error}`); - } - - return response.json() as Promise; - } - - /** - * Fetch paginated results from the IAM API. - * - * @param basePath - Base path for the API endpoint - * @param extractor - Function to extract items from response - * @returns All items across all pages - */ - private async fetchPaginated( - basePath: string, - extractor: (response: R) => T[] | undefined, - ): Promise { - const items: T[] = []; + async listServiceAccounts(projectId: string): Promise { + const accounts: ServiceAccount[] = []; let pageToken: string | undefined; do { - const params = new URLSearchParams(); - if (pageToken) { - params.set('pageToken', pageToken); + const res = await this.api.projects.serviceAccounts.list({ + name: `projects/${projectId}`, + pageToken, + }); + for (const sa of res.data.accounts ?? []) { + accounts.push({ + name: sa.name ?? '', + projectId: sa.projectId ?? projectId, + uniqueId: sa.uniqueId ?? '', + email: sa.email ?? '', + displayName: sa.displayName ?? undefined, + description: sa.description ?? undefined, + disabled: sa.disabled ?? false, + }); } - - const query = params.toString(); - const path = query ? `${basePath}?${query}` : basePath; - const response = await this.request(path); - - const extracted = extractor(response); - if (extracted) { - items.push(...extracted); - } - pageToken = response.nextPageToken; + pageToken = res.data.nextPageToken ?? undefined; } while (pageToken); - return items; + return accounts; } - /** - * List service accounts in a project. - * - * @param projectId - GCP project ID - * @returns List of service accounts - */ - async listServiceAccounts(projectId: string): Promise { - return this.fetchPaginated( - `/projects/${projectId}/serviceAccounts`, - (response) => response.accounts, - ); - } - - /** - * Get a specific service account. - * - * @param projectId - GCP project ID - * @param serviceAccountEmail - Service account email - * @returns Service account details - */ async getServiceAccount(projectId: string, serviceAccountEmail: string): Promise { - return this.request( - `/projects/${projectId}/serviceAccounts/${serviceAccountEmail}`, - ); + const res = await this.api.projects.serviceAccounts.get({ + name: `projects/${projectId}/serviceAccounts/${serviceAccountEmail}`, + }); + const sa = res.data; + return { + name: sa.name ?? '', + projectId: sa.projectId ?? projectId, + uniqueId: sa.uniqueId ?? '', + email: sa.email ?? '', + displayName: sa.displayName ?? undefined, + description: sa.description ?? undefined, + disabled: sa.disabled ?? false, + }; } - /** - * Create a new service account. - * - * @param projectId - GCP project ID - * @param accountId - Service account ID (part before @) - * @param displayName - Optional display name - * @param description - Optional description - * @returns Created service account - */ async createServiceAccount( projectId: string, accountId: string, displayName?: string, description?: string, ): Promise { - const request: CreateServiceAccountRequest = { - accountId, - serviceAccount: { - displayName, - description, + const res = await this.api.projects.serviceAccounts.create({ + name: `projects/${projectId}`, + requestBody: { + accountId, + serviceAccount: { displayName, description }, }, + }); + const sa = res.data; + return { + name: sa.name ?? '', + projectId: sa.projectId ?? projectId, + uniqueId: sa.uniqueId ?? '', + email: sa.email ?? '', + displayName: sa.displayName ?? undefined, + description: sa.description ?? undefined, + disabled: sa.disabled ?? false, }; - - return this.postRequest(`/projects/${projectId}/serviceAccounts`, request); } - /** - * Create a new key for a service account. - * - * The key is returned in the response and cannot be retrieved again. - * Store it securely. - * - * @param projectId - GCP project ID - * @param serviceAccountEmail - Service account email - * @returns Service account key with private key data (base64 encoded) - */ async createServiceAccountKey( projectId: string, serviceAccountEmail: string, ): Promise { - const request: CreateServiceAccountKeyRequest = { - privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE', - keyAlgorithm: 'KEY_ALG_RSA_2048', + const res = await this.api.projects.serviceAccounts.keys.create({ + name: `projects/${projectId}/serviceAccounts/${serviceAccountEmail}`, + requestBody: { + privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE', + keyAlgorithm: 'KEY_ALG_RSA_2048', + }, + }); + const key = res.data; + return { + name: key.name ?? '', + privateKeyType: key.privateKeyType ?? '', + keyAlgorithm: key.keyAlgorithm ?? '', + privateKeyData: key.privateKeyData ?? '', + validAfterTime: key.validAfterTime ?? '', + validBeforeTime: key.validBeforeTime ?? '', }; - - return this.postRequest( - `/projects/${projectId}/serviceAccounts/${serviceAccountEmail}/keys`, - request, - ); } - /** - * Create a service account and generate a key in one operation. - * - * This is a convenience method that combines createServiceAccount and createServiceAccountKey. - * - * @param projectId - GCP project ID - * @param accountId - Service account ID (part before @) - * @param displayName - Optional display name - * @returns Object containing the service account and its key - */ async createServiceAccountWithKey( projectId: string, accountId: string, @@ -211,9 +120,7 @@ export class IamApiClient { displayName, 'Created by Clix CLI for Firebase Admin SDK', ); - const key = await this.createServiceAccountKey(projectId, serviceAccount.email); - return { serviceAccount, key }; } } diff --git a/src/lib/services/firebase/api/index.ts b/src/lib/services/firebase/api/index.ts index 1d0d894..7decd1b 100644 --- a/src/lib/services/firebase/api/index.ts +++ b/src/lib/services/firebase/api/index.ts @@ -8,21 +8,11 @@ export { FirebaseApiClient } from './firebase-api'; export { IamApiClient } from './iam-api'; export type { AndroidApp, - AppConfigResponse, CreateAndroidAppRequest, CreateIosAppRequest, - CreateServiceAccountKeyRequest, - CreateServiceAccountRequest, - FirebaseApiError, FirebaseProject, GcpProject, IosApp, - ListAndroidAppsResponse, - ListGcpProjectsResponse, - ListIosAppsResponse, - ListProjectsResponse, - ListServiceAccountsResponse, - Operation, ServiceAccount, ServiceAccountJson, ServiceAccountKey, diff --git a/src/lib/services/firebase/api/types.ts b/src/lib/services/firebase/api/types.ts index bfb502e..ff80420 100644 --- a/src/lib/services/firebase/api/types.ts +++ b/src/lib/services/firebase/api/types.ts @@ -4,425 +4,77 @@ * @module services/firebase/api/types */ -/** - * Firebase project information. - */ export interface FirebaseProject { - /** - * Resource name (e.g., "projects/my-project-id") - */ name: string; - - /** - * Project ID (e.g., "my-project-id") - */ projectId: string; - - /** - * Project number. - */ projectNumber: string; - - /** - * Display name (user-friendly name). - */ displayName: string; - - /** - * Project state. - */ state: 'ACTIVE' | 'DELETED'; } -/** - * Firebase Android app information. - */ export interface AndroidApp { - /** - * Resource name. - */ name: string; - - /** - * App ID (e.g., "1:123456789:android:abcdef") - */ appId: string; - - /** - * Display name. - */ displayName?: string; - - /** - * Android package name (e.g., "com.example.app") - */ packageName: string; - - /** - * Project ID. - */ projectId: string; } -/** - * Firebase iOS app information. - */ export interface IosApp { - /** - * Resource name. - */ name: string; - - /** - * App ID (e.g., "1:123456789:ios:abcdef") - */ appId: string; - - /** - * Display name. - */ displayName?: string; - - /** - * iOS bundle ID (e.g., "com.example.app") - */ bundleId: string; - - /** - * Project ID. - */ projectId: string; } -/** - * API response for listing projects. - */ -export interface ListProjectsResponse { - results?: FirebaseProject[]; - nextPageToken?: string; -} - -/** - * API response for listing Android apps. - */ -export interface ListAndroidAppsResponse { - apps?: AndroidApp[]; - nextPageToken?: string; -} - -/** - * API response for listing iOS apps. - */ -export interface ListIosAppsResponse { - apps?: IosApp[]; - nextPageToken?: string; -} - -/** - * API response for app config. - */ -export interface AppConfigResponse { - /** - * Config filename (e.g., "google-services.json") - */ - configFilename: string; - - /** - * Base64-encoded config file contents. - */ - configFileContents: string; -} - -/** - * Firebase API error. - */ -export interface FirebaseApiError { - code: number; - message: string; - status: string; -} - -/** - * Request body for creating an Android app. - */ export interface CreateAndroidAppRequest { - /** - * Android package name (e.g., "com.example.app") - */ packageName: string; - - /** - * Display name for the app. - */ displayName?: string; } -/** - * Request body for creating an iOS app. - */ export interface CreateIosAppRequest { - /** - * iOS bundle ID (e.g., "com.example.app") - */ bundleId: string; - - /** - * Display name for the app. - */ displayName?: string; } -/** - * Long-running operation response. - * Firebase app creation returns an operation that completes asynchronously. - */ -export interface Operation { - /** - * Operation name (e.g., "operations/abc123") - */ - name: string; - - /** - * Whether the operation is done. - */ - done: boolean; - - /** - * Operation result when done is true. - */ - response?: T; - - /** - * Error details if operation failed. - */ - error?: { - code: number; - message: string; - details?: unknown[]; - }; -} - -// ============================================================================ -// GCP Project Types -// ============================================================================ - -/** - * Google Cloud Platform project information. - * Used for listing GCP projects that can have Firebase added. - */ export interface GcpProject { - /** - * Project ID (e.g., "my-project-id") - */ projectId: string; - - /** - * Project display name. - */ name: string; - - /** - * Project number. - */ projectNumber: string; - - /** - * Project lifecycle state. - */ lifecycleState: 'ACTIVE' | 'DELETE_REQUESTED' | 'DELETE_IN_PROGRESS'; - - /** - * Project creation time. - */ createTime?: string; } -/** - * API response for listing GCP projects. - */ -export interface ListGcpProjectsResponse { - projects?: GcpProject[]; - nextPageToken?: string; -} - -// ============================================================================ -// Service Account Types -// ============================================================================ - -/** - * Google Cloud Service Account. - */ export interface ServiceAccount { - /** - * Resource name (e.g., "projects/{project}/serviceAccounts/{email}") - */ name: string; - - /** - * Project ID this service account belongs to. - */ projectId: string; - - /** - * Unique ID. - */ uniqueId: string; - - /** - * Service account email (e.g., "name@project.iam.gserviceaccount.com") - */ email: string; - - /** - * Display name. - */ displayName?: string; - - /** - * Description. - */ description?: string; - - /** - * Whether the service account is disabled. - */ disabled?: boolean; } -/** - * Request body for creating a service account. - */ -export interface CreateServiceAccountRequest { - /** - * Service account ID (the part before @ in the email). - * Must be between 6 and 30 characters, lowercase letters, digits, and hyphens. - */ - accountId: string; - - /** - * Service account details. - */ - serviceAccount: { - displayName?: string; - description?: string; - }; -} - -/** - * API response for listing service accounts. - */ -export interface ListServiceAccountsResponse { - accounts?: ServiceAccount[]; - nextPageToken?: string; -} - -/** - * Service account key. - */ export interface ServiceAccountKey { - /** - * Resource name. - */ name: string; - - /** - * Private key type. - */ privateKeyType: string; - - /** - * Key algorithm. - */ keyAlgorithm: string; - - /** - * Base64-encoded private key data (JSON format). - */ privateKeyData: string; - - /** - * Key valid after time. - */ validAfterTime: string; - - /** - * Key valid before time. - */ validBeforeTime: string; } -/** - * Request body for creating a service account key. - */ -export interface CreateServiceAccountKeyRequest { - /** - * Private key type. Use 'TYPE_GOOGLE_CREDENTIALS_FILE' for JSON format. - */ - privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE' | 'TYPE_PKCS12_FILE'; - - /** - * Key algorithm. - */ - keyAlgorithm?: 'KEY_ALG_RSA_2048' | 'KEY_ALG_RSA_1024'; -} - -/** - * Firebase/Google Cloud Service Account JSON key file structure. - * This is the format of the downloaded JSON key file. - */ export interface ServiceAccountJson { - /** - * Always "service_account". - */ type: 'service_account'; - - /** - * GCP project ID. - */ project_id: string; - - /** - * Private key ID. - */ private_key_id: string; - - /** - * Private key in PEM format. - */ private_key: string; - - /** - * Service account email. - */ client_email: string; - - /** - * Client ID. - */ client_id: string; - - /** - * Auth URI. - */ auth_uri: string; - - /** - * Token URI. - */ token_uri: string; - - /** - * Auth provider certificate URL. - */ auth_provider_x509_cert_url: string; - - /** - * Client certificate URL. - */ client_x509_cert_url: string; - - /** - * Universe domain (optional, for specialized environments). - */ universe_domain?: string; } diff --git a/src/lib/skills.ts b/src/lib/skills.ts index d39a0c0..2c23d1f 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; +import type { PreparationContext } from '@/commands/skill/preparation'; import { EMBEDDED_SKILL_METADATA, getEmbeddedSkill, @@ -10,6 +11,7 @@ import { } from './embedded-skills'; import type { AgentExecutor, AgentMessage } from './executor'; import { getDebugPrompt } from './services/debug-service'; +import { formatProjectType } from './services/project-detector'; /** * Skill type - dynamically generated from embedded skills + local skills. @@ -23,6 +25,8 @@ export interface SkillOptions { signal?: AbortSignal; /** One-shot mode: disable session persistence (for command-line execution) */ oneShot?: boolean; + /** Preparation context from install preparation phase */ + preparationContext?: PreparationContext; } export interface SkillInfo { @@ -228,6 +232,66 @@ function readLocalSkillPrompt(skillName: string): string { } } +/** + * Build pre-configured setup section from preparation context. + */ +function buildPreparationSection(context: PreparationContext): string { + const lines: string[] = ['## Pre-configured Setup', '']; + lines.push(`Project: ${context.config.project.name}`); + lines.push(`Type: ${formatProjectType(context.projectType)}`); + lines.push(''); + + // Clix project info + lines.push('### Clix Project'); + lines.push(`- Project ID: ${context.config.project.id}`); + if (context.config.project.publicKey) { + lines.push(`- Public Key: ${context.config.project.publicKey}`); + } + lines.push(''); + + // Firebase status + if (context.firebase.needed) { + lines.push('### Firebase'); + lines.push(`- Project ID: ${context.firebase.projectId || 'not configured'}`); + lines.push( + `- Android (google-services.json): ${context.firebase.androidConfigured ? '✓ configured' : '✗ missing'}`, + ); + lines.push( + `- iOS (GoogleService-Info.plist): ${context.firebase.iosConfigured ? '✓ configured' : '✗ missing'}`, + ); + lines.push(''); + } + + // iOS status + if (context.ios.needed) { + lines.push('### iOS'); + lines.push(`- Bundle ID: ${context.ios.bundleId || 'not detected'}`); + lines.push(`- Team ID: ${context.ios.teamId || 'not detected'}`); + lines.push(`- App Group: ${context.ios.appGroupId || 'not configured'}`); + lines.push( + `- Entitlements: ${context.ios.entitlementsConfigured ? '✓ configured' : '✗ not configured'}`, + ); + lines.push( + `- NSE (Notification Service Extension): ${context.ios.nseConfigured ? '✓ configured' : '✗ not configured'}`, + ); + lines.push(''); + } + + // Missing items + if (context.missing.length > 0) { + lines.push('### Missing Setup (handle during installation)'); + for (const item of context.missing) { + lines.push(`- ${item}`); + } + lines.push(''); + } + + lines.push('Use these pre-configured values when integrating the SDK.'); + lines.push(''); + + return lines.join('\n'); +} + /** * Get prompt for the install skill. * Uses the autonomous installation prompt optimized for one-shot execution. @@ -236,9 +300,16 @@ function readLocalSkillPrompt(skillName: string): string { function getInstallPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); const platform = options?.platform ?? 'auto-detect'; + const context = options?.preparationContext; let prompt = `Project path: ${projectPath}\nTarget platform: ${platform}\n\n`; + // Add preparation context if available + if (context) { + prompt += buildPreparationSection(context); + prompt += '\n'; + } + // Add one-shot instruction for autonomous execution if (options?.oneShot) { prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx new file mode 100644 index 0000000..a5a17f1 --- /dev/null +++ b/src/ui/components/InstallPreparationUI.tsx @@ -0,0 +1,445 @@ +/** + * Install preparation UI component. + * + * Orchestrates the preparation steps before SDK installation: + * 1. Check project config + * 2. Firebase setup (if needed) + * 3. iOS setup (if needed) + * 4. Ready for agent execution + * + * @module ui/components/InstallPreparationUI + */ + +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { FirebaseSetupResult } from '@/lib/services/firebase'; +import { formatProjectType } from '@/lib/services/project-detector'; +import { useCancelInput } from '@/ui/hooks'; +import { + type FirebaseStatus, + gatherPreparationContext, + type PreparationContext, + saveSetupStatus, +} from '../../commands/skill/preparation'; +import { FirebaseWizard } from './FirebaseWizard'; +import { GenericSelector, type SelectorItem } from './GenericSelector'; + +type PreparationPhase = + | 'checking' + | 'config_missing' + | 'firebase_check' + | 'firebase_setup' + | 'ios_check' + | 'ios_setup' + | 'ready' + | 'cancelled'; + +interface InstallPreparationUIProps { + projectPath?: string; + onComplete: (context: PreparationContext) => void; + onCancel: () => void; +} + +interface ActionItem extends SelectorItem { + action: 'configure' | 'skip' | 'cancel'; +} + +function StatusLine({ + label, + status, + detail, +}: { + label: string; + status: 'pending' | 'checking' | 'ok' | 'missing' | 'skipped'; + detail?: string; +}): React.ReactElement { + const icon = + status === 'ok' + ? '✓' + : status === 'missing' + ? '✗' + : status === 'skipped' + ? '○' + : status === 'checking' + ? '○' + : '○'; + const color = + status === 'ok' + ? 'green' + : status === 'missing' + ? 'red' + : status === 'skipped' + ? 'yellow' + : 'gray'; + + return ( + + {icon} + {label} + {detail && ({detail})} + {status === 'checking' && ( + + {' '} + + + )} + + ); +} + +function CheckingPhase(): React.ReactElement { + return ( + + + + + + Checking project configuration... + + + ); +} + +function ConfigMissingPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { + useCancelInput(onCancel); + + return ( + + ✗ Project not linked + Run "clix login" first to link this project. + + Press Esc to exit + + + ); +} + +function StatusPhase({ + context, + onContinue, + onCancel, +}: { + context: PreparationContext; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + const items: ActionItem[] = [ + { + id: 'continue', + label: context.ready ? 'Continue to installation' : 'Continue anyway (some setup missing)', + action: 'configure', + }, + { id: 'cancel', label: 'Cancel', action: 'cancel' }, + ]; + + const handleSelect = useCallback( + (item: ActionItem) => { + if (item.action === 'cancel') { + onCancel(); + } else { + onContinue(); + } + }, + [onCancel, onContinue], + ); + + return ( + + Install Preparation + + + + {context.firebase.needed && ( + + )} + {context.ios.needed && ( + <> + + + + )} + + + {context.missing.length > 0 && ( + + Missing setup: + {context.missing.map((item) => ( + + • {item} + + ))} + + )} + + + + ); +} + +function FirebaseSetupPhase({ + context, + onComplete, + onCancel, +}: { + context: PreparationContext; + onComplete: (result: FirebaseSetupResult) => void; + onCancel: () => void; +}): React.ReactElement { + return ( + + Firebase Setup + + Firebase configuration is required for push notifications. + + + + ); +} + +function ReadyPhase({ + context, + onContinue, + onCancel, +}: { + context: PreparationContext; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + useCancelInput(onCancel); + + useEffect(() => { + // Auto-continue after a brief moment + const timer = setTimeout(onContinue, 500); + return () => clearTimeout(timer); + }, [onContinue]); + + return ( + + + ✓ Preparation complete + + + Project: {context.config.project.name} + Type: {formatProjectType(context.projectType)} + {context.firebase.projectId && Firebase: {context.firebase.projectId}} + + + + + + Starting SDK installation... + + + ); +} + +/** + * Install preparation UI component. + * + * Guides user through setup steps before SDK installation. + */ +export function InstallPreparationUI({ + projectPath = process.cwd(), + onComplete, + onCancel, +}: InstallPreparationUIProps): React.ReactElement { + const [phase, setPhase] = useState('checking'); + const [context, setContext] = useState(null); + + // Gather preparation context on mount + useEffect(() => { + let mounted = true; + + async function check() { + const ctx = await gatherPreparationContext(projectPath); + + if (!mounted) return; + + if (!ctx) { + setPhase('config_missing'); + return; + } + + setContext(ctx); + + // Determine which setup phase to show + if (!ctx.firebase.configured && ctx.firebase.needed) { + setPhase('firebase_check'); + } else if (!ctx.ios.entitlementsConfigured && ctx.ios.needed) { + setPhase('ios_check'); + } else if (ctx.ready) { + setPhase('ready'); + } else { + // Some setup missing but not critical - show status + setPhase('firebase_check'); + } + } + + check(); + + return () => { + mounted = false; + }; + }, [projectPath]); + + const handleFirebaseComplete = useCallback( + async (result: FirebaseSetupResult) => { + if (!context) return; + + // Extract status from detection result + const detection = result.detection; + const androidConfigured = detection?.android?.valid ?? context.firebase.androidConfigured; + const iosConfigured = detection?.ios?.valid ?? context.firebase.iosConfigured; + + // Extract project ID from detection + let projectId = context.firebase.projectId; + if (detection?.android?.content && 'project_info' in detection.android.content) { + projectId = detection.android.content.project_info?.project_id; + } else if (detection?.ios?.content && 'PROJECT_ID' in detection.ios.content) { + projectId = detection.ios.content.PROJECT_ID; + } + + // Update Firebase status based on result + const updatedFirebase: FirebaseStatus = { + ...context.firebase, + configured: result.completed && androidConfigured && iosConfigured, + androidConfigured, + iosConfigured, + projectId, + }; + + const updatedContext: PreparationContext = { + ...context, + firebase: updatedFirebase, + missing: context.missing.filter((m) => !m.includes('Firebase')), + ready: + updatedFirebase.configured && + (!context.ios.needed || + (context.ios.entitlementsConfigured && context.ios.nseConfigured)), + }; + + setContext(updatedContext); + + // Save setup status + await saveSetupStatus(projectPath, updatedFirebase, context.ios); + + // Move to next phase + if (context.ios.needed && !context.ios.entitlementsConfigured) { + setPhase('ios_check'); + } else { + setPhase('ready'); + } + }, + [context, projectPath], + ); + + const handleIosSkip = useCallback(() => { + setPhase('ready'); + }, []); + + const handleContinue = useCallback(() => { + if (!context) return; + + if (phase === 'firebase_check') { + if (!context.firebase.configured && context.firebase.needed) { + setPhase('firebase_setup'); + } else if (context.ios.needed && !context.ios.entitlementsConfigured) { + setPhase('ios_check'); + } else { + setPhase('ready'); + } + } else if (phase === 'ios_check') { + if (!context.ios.entitlementsConfigured && context.ios.needed) { + setPhase('ios_setup'); + } else { + setPhase('ready'); + } + } else if (phase === 'ready') { + onComplete(context); + } + }, [context, phase, onComplete]); + + const handleCancel = useCallback(() => { + setPhase('cancelled'); + onCancel(); + }, [onCancel]); + + // Render based on phase + switch (phase) { + case 'checking': + return ; + + case 'config_missing': + return ; + + case 'firebase_check': + if (!context) return ; + return ; + + case 'firebase_setup': + if (!context) return ; + return ( + + ); + + case 'ios_check': + if (!context) return ; + return ; + + case 'ios_setup': + // For now, skip iOS setup wizard (complex integration) + // The agent will handle iOS setup if needed + if (!context) return ; + handleIosSkip(); + return ; + + case 'ready': + if (!context) return ; + return ( + onComplete(context)} + onCancel={handleCancel} + /> + ); + + case 'cancelled': + return ( + + Installation cancelled. + + ); + + default: + return ; + } +} From 4e604c5afb7035827524d9f1f8cda4463a32fd8a Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Fri, 6 Feb 2026 14:59:38 +0900 Subject: [PATCH 03/30] refactor(config): simplify project config by removing V1/V2 versioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove backward compatibility code for V1→V2 migration: - Unify ProjectConfigV1Schema and ProjectConfigV2Schema into single ProjectConfigSchema - Change version field from z.literal(2) to z.number() for flexibility - Remove isConfigV1(), isConfigV2(), migrateV1ToV2() functions - Keep ensureLatestVersion() framework for future migrations - Remove loadV2() method, use load() directly - Update CURRENT_PROJECT_CONFIG_VERSION to 1 Co-Authored-By: Claude Opus 4.5 --- src/commands/skill/preparation.ts | 14 ++--- src/lib/config/index.ts | 7 --- src/lib/config/project-config-manager.ts | 20 +------ src/lib/config/project-config-schema.ts | 75 ++++-------------------- 4 files changed, 19 insertions(+), 97 deletions(-) diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts index 6f4a7c3..31d0b39 100644 --- a/src/commands/skill/preparation.ts +++ b/src/commands/skill/preparation.ts @@ -9,7 +9,7 @@ import { getProjectConfigManager, - type ProjectConfigV2, + type ProjectConfig, type ProjectType, type SetupStatus, } from '@/lib/config'; @@ -57,7 +57,7 @@ export interface PreparationContext { /** Project root path */ projectPath: string; /** Loaded and migrated config */ - config: ProjectConfigV2; + config: ProjectConfig; /** Detected or loaded project type */ projectType: ProjectType; /** Firebase configuration status */ @@ -77,7 +77,7 @@ export interface ProjectLinkStatus { /** Whether project is linked */ linked: boolean; /** Config if linked */ - config?: ProjectConfigV2; + config?: ProjectConfig; /** Error message if not linked */ error?: string; } @@ -92,7 +92,7 @@ export async function checkProjectLinked(projectPath?: string): Promise { +): Promise<{ config: ProjectConfig; projectType: ProjectType }> { if (config.projectType) { return { config, projectType: config.projectType }; } @@ -134,7 +134,7 @@ export async function ensureProjectType( // Save to config const manager = getProjectConfigManager(projectPath); - const updatedConfig: ProjectConfigV2 = { + const updatedConfig: ProjectConfig = { ...config, projectType, }; diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index 8f3c1ff..24840b2 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -16,17 +16,10 @@ export { FirebaseSetupSchema, type IosSetup, IosSetupSchema, - isConfigV1, - isConfigV2, - migrateV1ToV2, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, type ProjectConfig, ProjectConfigSchema, - type ProjectConfigV1, - ProjectConfigV1Schema, - type ProjectConfigV2, - ProjectConfigV2Schema, type ProjectFramework, type ProjectInfo, ProjectInfoSchema, diff --git a/src/lib/config/project-config-manager.ts b/src/lib/config/project-config-manager.ts index cc9b480..e8d350e 100644 --- a/src/lib/config/project-config-manager.ts +++ b/src/lib/config/project-config-manager.ts @@ -4,11 +4,9 @@ import { ConfigError, ERROR_CODES } from '../errors/types'; import { CURRENT_PROJECT_CONFIG_VERSION, ensureLatestVersion, - isConfigV2, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, type ProjectConfig, - type ProjectConfigV2, type SetupStatus, safeValidateProjectConfig, } from './project-config-schema'; @@ -166,20 +164,6 @@ export class ProjectConfigManager { } } - /** - * Load config and ensure it's at latest version (v2). - * Returns null if config doesn't exist. - * - * @returns ProjectConfigV2 or null if not found - */ - async loadV2(): Promise { - const config = await this.load(); - if (!config) { - return null; - } - return isConfigV2(config) ? config : ensureLatestVersion(config); - } - /** * Update the setup status in config. * Creates setup object if it doesn't exist. @@ -187,7 +171,7 @@ export class ProjectConfigManager { * @param updates - Partial setup status to merge */ async updateSetup(updates: Partial): Promise { - const config = await this.loadV2(); + const config = await this.load(); if (!config) { throw new ConfigError( 'Project config not found. Run "clix login" first.', @@ -196,7 +180,7 @@ export class ProjectConfigManager { ); } - const updatedConfig: ProjectConfigV2 = { + const updatedConfig: ProjectConfig = { ...config, setup: { ...config.setup, diff --git a/src/lib/config/project-config-schema.ts b/src/lib/config/project-config-schema.ts index 9eb445b..639cf05 100644 --- a/src/lib/config/project-config-schema.ts +++ b/src/lib/config/project-config-schema.ts @@ -115,31 +115,12 @@ export const SetupStatusSchema = z.object({ }); /** - * Main project configuration schema (version 1 - legacy). + * Main project configuration schema. * Stored in .clix/config.jsonc in the project root. */ -export const ProjectConfigV1Schema = z.object({ +export const ProjectConfigSchema = z.object({ /** Configuration schema version */ - version: z.literal(1), - /** Logged-in member information */ - member: ProjectMemberSchema, - /** Selected organization */ - organization: ProjectOrganizationSchema, - /** Selected project */ - project: ProjectInfoSchema, - /** Detected project type (framework + target platform) */ - projectType: ProjectTypeSchema.optional(), - /** ISO timestamp when this config was created/linked */ - linkedAt: z.string().datetime(), -}); - -/** - * Main project configuration schema (version 2 - current). - * Stored in .clix/config.jsonc in the project root. - */ -export const ProjectConfigV2Schema = z.object({ - /** Configuration schema version */ - version: z.literal(2), + version: z.number(), /** Logged-in member information */ member: ProjectMemberSchema, /** Selected organization */ @@ -154,11 +135,6 @@ export const ProjectConfigV2Schema = z.object({ setup: SetupStatusSchema.optional(), }); -/** - * Union schema that accepts both v1 and v2 configs. - */ -export const ProjectConfigSchema = z.union([ProjectConfigV1Schema, ProjectConfigV2Schema]); - /** * Type definitions derived from schemas. */ @@ -169,14 +145,12 @@ export type IosSetup = z.infer; export type FirebaseSetup = z.infer; export type ApnsSetup = z.infer; export type SetupStatus = z.infer; -export type ProjectConfigV1 = z.infer; -export type ProjectConfigV2 = z.infer; export type ProjectConfig = z.infer; /** * Current project config version. */ -export const CURRENT_PROJECT_CONFIG_VERSION = 2; +export const CURRENT_PROJECT_CONFIG_VERSION = 1; /** * Project config file name. @@ -212,43 +186,14 @@ export function safeValidateProjectConfig(data: unknown): ProjectConfig | null { } /** - * Check if config is version 1. - */ -export function isConfigV1(config: ProjectConfig): config is ProjectConfigV1 { - return config.version === 1; -} - -/** - * Check if config is version 2. - */ -export function isConfigV2(config: ProjectConfig): config is ProjectConfigV2 { - return config.version === 2; -} - -/** - * Migrate v1 config to v2. - * - * @param config - v1 config to migrate - * @returns Migrated v2 config - */ -export function migrateV1ToV2(config: ProjectConfigV1): ProjectConfigV2 { - return { - ...config, - version: 2, - setup: undefined, - }; -} - -/** - * Ensure config is at the latest version (v2). + * Ensure config is at the latest version. * Migrates older versions if necessary. * * @param config - Config of any supported version - * @returns Config at latest version (v2) + * @returns Config at latest version */ -export function ensureLatestVersion(config: ProjectConfig): ProjectConfigV2 { - if (isConfigV2(config)) { - return config; - } - return migrateV1ToV2(config); +export function ensureLatestVersion(config: ProjectConfig): ProjectConfig { + // Currently no migration needed + // Add migration logic here when schema changes in future versions + return config; } From f2ea2b3881ec160f1e64dc7870c44dcecd5a1bd1 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Wed, 11 Feb 2026 15:56:04 +0900 Subject: [PATCH 04/30] feat(firebase): refactor APIs, add sender config, and improve wizard UX - Migrate Firebase API clients from REST to googleapis library - Remove IAM API (SA keys now downloaded from Firebase Console) - Add Sender Config API integration (check/register push notification config) - Extract FirebaseWizard PHASE_TRANSITIONS state machine with transition() validation - Add Organization/Projects concurrent fetching service - Add ProjectSelector search/filter functionality - Improve InstallPreparationUI with explicit action menus - Add OAuth debug logging infrastructure - Simplify OAuth scopes to firebase-only - Add exponential backoff retry logic for API calls --- AGENTS.md | 26 + bun.lock | 3 - package.json | 1 - plan.md | 133 +++ .../skill/__tests__/preparation.test.ts | 46 +- src/commands/skill/preparation.ts | 17 +- src/lib/api/index.ts | 2 + src/lib/api/internal-client.ts | 169 +++- src/lib/api/types.ts | 19 + src/lib/auth/credentials.ts | 9 +- src/lib/debug/logger.ts | 45 + .../__tests__/organization-projects.test.ts | 113 +++ src/lib/services/firebase/api/firebase-api.ts | 229 +++-- src/lib/services/firebase/api/iam-api.ts | 126 --- src/lib/services/firebase/api/index.ts | 5 +- src/lib/services/firebase/api/types.ts | 19 - src/lib/services/firebase/downloader.ts | 131 +-- src/lib/services/firebase/index.ts | 2 - .../services/firebase/oauth/auth-client.ts | 115 ++- src/lib/services/firebase/oauth/config.ts | 8 +- src/lib/services/organization-projects.ts | 125 +++ src/lib/utils/oauth.ts | 30 +- src/ui/LoginUI.tsx | 46 +- src/ui/SetupUI.tsx | 37 +- src/ui/chat/ChatApp.tsx | 18 + src/ui/chat/hooks/useCommandHandler.ts | 10 +- src/ui/chat/hooks/useMessageSending.ts | 4 +- src/ui/chat/hooks/useOverlays.ts | 24 + src/ui/components/FirebaseWizard.tsx | 825 +++++++++--------- src/ui/components/InstallPreparationUI.tsx | 98 ++- src/ui/components/ProjectSelector.tsx | 139 ++- .../firebase-wizard-transitions.test.ts | 226 +++++ .../components/firebase-wizard-transitions.ts | 193 ++++ 33 files changed, 2043 insertions(+), 950 deletions(-) create mode 100644 plan.md create mode 100644 src/lib/services/__tests__/organization-projects.test.ts delete mode 100644 src/lib/services/firebase/api/iam-api.ts create mode 100644 src/lib/services/organization-projects.ts create mode 100644 src/ui/components/__tests__/firebase-wizard-transitions.test.ts create mode 100644 src/ui/components/firebase-wizard-transitions.ts diff --git a/AGENTS.md b/AGENTS.md index 13dddb9..ed1bca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,32 @@ Key files: `src/ui/chat/ChatApp.tsx` - Main component with context provider. State split into focused hooks under `src/ui/chat/hooks/`. +### FirebaseWizard State Machine + +`src/ui/components/FirebaseWizard.tsx` - Multi-phase wizard for Firebase configuration. Uses a centralized state machine pattern. + +**Phase transition map**: `src/ui/components/firebase-wizard-transitions.ts` + +- `PHASE_TRANSITIONS` - All valid phase transitions in one map +- `transition(from, event)` - Validated transition function (throws on invalid transitions) +- `ExtendedWizardPhase` - Union type of all 24 phases + +**Rules when modifying FirebaseWizard**: + +1. **Update `PHASE_TRANSITIONS` first** - Add/modify transition rules in the map before changing handlers +2. **Use `transition()` for all `setPhase()` calls** - Never call `setPhase('phase')` directly; use `setPhase(transition('currentPhase', 'event'))` instead +3. **One `setPhase` per handler** - Avoid multiple `setPhase()` calls in a single async handler. If a handler needs multiple steps, split into separate phases with `useEffect` +4. **"Direct set" exceptions** - Only use direct `setPhase()` when the source phase is ambiguous (entry points from multiple phases). Add a `// Direct set:` comment explaining why + +**Flow overview**: +``` +detecting → status → menu → (actions) +menu/download → authenticating → select_project → select_*_app → downloading +downloading → checking_sender_config → service_account_menu → paste → saving → complete +``` + +**Tests**: `src/ui/components/__tests__/firebase-wizard-transitions.test.ts` (34 tests, 100% coverage on transition file) + ### Skills `src/lib/skills.ts` - Manages skill workflows. diff --git a/bun.lock b/bun.lock index 99b1788..55e03e5 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,6 @@ "@expo/plist": "^0.4.8", "@googleapis/cloudresourcemanager": "^6.0.1", "@googleapis/firebase": "^12.0.1", - "@googleapis/iam": "^36.0.0", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", "ink": "^6.6.0", @@ -74,8 +73,6 @@ "@googleapis/firebase": ["@googleapis/firebase@12.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-ODFU7EbB2G/WFsXiNCXjRgJPRxT5JYh3ydznNdsVng/1SnWmQZK8Bgufvxq6ZWnNDqZJTtNUqAwjbMBoP6N3MA=="], - "@googleapis/iam": ["@googleapis/iam@36.0.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-dPrhP3zswkP3+ADoJjuMQissm/4591545N+7nQ11YsEJKTvJCYnAfXjdkJ4QyThdLNIx8TnzRAU2PGW9Sv9NrA=="], - "@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=="], diff --git a/package.json b/package.json index f8f7ca4..f1bf6a3 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "@expo/plist": "^0.4.8", "@googleapis/cloudresourcemanager": "^6.0.1", "@googleapis/firebase": "^12.0.1", - "@googleapis/iam": "^36.0.0", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", "ink": "^6.6.0", diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..bef473d --- /dev/null +++ b/plan.md @@ -0,0 +1,133 @@ +# feat/refactor-firebase-apis 브랜치 작업 현황 리뷰 + +## Context + +이 브랜치는 Firebase API 클라이언트를 googleapis 라이브러리로 마이그레이션하고, Service Account 관리 방식을 변경하며, Sender Config API 연동과 FirebaseWizard 상태 머신 리팩터링을 포함하는 대규모 작업입니다. + +--- + +## 완료된 작업 (커밋 완료) + +| # | 커밋 | 내용 | +|---|------|------| +| 1 | `acc35b0` | feat(config): 자동 프로젝트 타입 감지 | +| 2 | `5e5ad31` | feat: 프로젝트 로컬 `.clix/` 스토리지 마이그레이션 | +| 3 | `354615f` | feat(firebase): SA 관리 및 프로젝트 생성 | +| 4 | `1c18441` | refactor(firebase): REST API → googleapis 라이브러리 전환 | +| 5 | `4e604c5` | refactor(config): V1/V2 버전 관리 제거로 프로젝트 설정 단순화 | + +--- + +## 완료된 작업 (아직 커밋 안 됨 — working tree) + +### 1. Firebase API googleapis 마이그레이션 (추가 정리) +- **파일**: `firebase-api.ts`, `types.ts`, `index.ts`, `downloader.ts` +- IAM API 클라이언트 완전 삭제 (`iam-api.ts` 파일 삭제) +- `@googleapis/iam` 의존성 제거 +- Service Account 생성/키 발급 메서드 제거 (사용자가 Firebase Console에서 직접 다운로드) +- API 호출마다 `debug.log`에 에러 로깅 추가 + +### 2. OAuth 스코프 단순화 + 디버깅 인프라 +- **파일**: `oauth/config.ts`, `oauth/auth-client.ts`, `logger.ts`, `oauth.ts` +- OAuth 스코프: firebase + iam + cloud-platform → **firebase만** +- `.clix/debug.log` 파일 기반 OAuth 디버그 로깅 추가 +- `invalid_grant` 에러 시 토큰 자동 클리어 + 재인증 시그널 +- OAuth 콜백 HTML 스타일 업데이트 + +### 3. Internal API — Sender Config 연동 +- **파일**: `api/types.ts`, `api/internal-client.ts`, `api/index.ts` +- `AppPushSenderConfig`, `SenderConfig` 타입 추가 +- `getProject()`, `createOrUpdateSenderConfig()` API 메서드 추가 +- 429/5xx/timeout에 대한 지수 백오프 재시도 로직 + +### 4. Firebase 감지 로직 수정 +- **파일**: `preparation.ts`, `preparation.test.ts` +- `checkFirebaseStatus()`가 캐시된 `.clix/config.jsonc`만 보지 않고 항상 실제 파일 탐지 수행 +- 파일에 프로젝트 ID가 없을 때 캐시된 projectId로 폴백 + +### 5. FirebaseWizard 상태 머신 리팩터링 +- **파일**: `firebase-wizard-transitions.ts` (신규), `FirebaseWizard.tsx`, `__tests__/firebase-wizard-transitions.test.ts` (신규) +- `PHASE_TRANSITIONS` 중앙 집중 맵 + `transition()` 검증 함수 추출 +- 모든 `setPhase()` 호출을 `transition(from, event)`로 교체 +- `handleSaveServiceAccountJson` 분리: 핸들러는 1번만 전이, API 호출은 useEffect로 분리 +- 34개 플로우 경로 테스트 (100% 커버리지) + +### 6. FirebaseWizard 새 기능 +- **파일**: `FirebaseWizard.tsx` +- Sender config 확인 3개 phase 추가 (`checking_sender_config`, `sender_config_registered`, `registering_sender_config`) +- `PasteServiceAccountPhase` 개선: 클립보드/JSON 직접입력/파일 드래그 자동 감지 +- Service Account 메뉴 단순화: "Create new" 제거, Console 다운로드 + Paste만 제공 + +### 7. Chat UI 통합 +- **파일**: `ChatApp.tsx`, `useOverlays.ts`, `useCommandHandler.ts`, `useMessageSending.ts` +- `install-preparation` 오버레이 타입 추가 +- `/install` 커맨드 실행 시 preparation UI 먼저 표시 +- `clixProjectId`를 `FirebaseWizard`에 전달 + +### 8. InstallPreparationUI 개선 +- **파일**: `InstallPreparationUI.tsx` +- 초기 진입 phase 판별 로직 추가 +- Ready phase에서 auto-continue 대신 명시적 액션 메뉴 제공 + +### 9. ProjectSelector 검색 기능 +- **파일**: `ProjectSelector.tsx` +- 프로젝트 검색/필터 기능 추가 +- 알파벳 정렬 (`Intl.Collator`) + +### 10. Organization/Projects 서비스 추출 +- **파일**: `organization-projects.ts` (신규), `organization-projects.test.ts` (신규) +- 동시 프로젝트 조회 (concurrency: 4) +- 지수 백오프 재시도 +- LoginUI, SetupUI에서 중복 로직 제거 + +### 11. 인증 관련 +- **파일**: `credentials.ts`, `LoginUI.tsx`, `SetupUI.tsx` +- `clearFirebaseTokens()` 견고성 개선 +- 조직/프로젝트 조회를 새 서비스로 이전 + +### 12. 문서화 +- **파일**: `AGENTS.md` +- FirebaseWizard 상태 머신 섹션 추가 (수정 규칙 4가지, 플로우 개요, 테스트 정보) + +--- + +## 검증 상태 + +| 검증 항목 | 상태 | +|-----------|------| +| `bun run check` (lint + typecheck) | ✅ 통과 | +| `bun test` (623 unit tests) | ✅ 통과 | +| `bun run build` | ✅ 성공 | +| `bun test tests/e2e/` (20 E2E tests) | ✅ 통과 | +| firebase-wizard-transitions 커버리지 | ✅ 100% | + +--- + +## 변경 파일 요약 (32개 파일, ~2,200줄) + +| 카테고리 | 파일 수 | 주요 변경량 | +|----------|---------|-----------| +| Firebase API 마이그레이션 | 5 | IAM 삭제, googleapis 적용 | +| OAuth/디버깅 | 4 | 스코프 단순화, debug.log | +| Internal API (sender config) | 3 | 재시도 로직, 새 API 메서드 | +| FirebaseWizard 리팩터링 | 3 | 전이 맵, 34 테스트 | +| Install preparation | 3 | 파일 탐지 개선 | +| Chat UI 통합 | 6 | preparation 오버레이 | +| ProjectSelector | 1 | 검색/필터 | +| Organization 서비스 | 2 | 동시 조회 추출 | +| 인증/기타 | 3 | credentials 개선 | +| 문서/의존성 | 3 | AGENTS.md, package.json | + +--- + +## 남은 작업 + +### 필수 +1. **E2E 수동 테스트** — 실제 Firebase 프로젝트로 전체 플로우 테스트 (OAuth → 다운로드 → Sender Config → SA 등록) +2. **PR 생성** — main 브랜치로 PR 생성 및 리뷰 요청 + +### 식별된 리스크 +- Sender Config 권한 불일치 → 명시적 에러 매핑으로 완화 +- FirebaseWizard phase 회귀 → transition-map 강제 + path 테스트로 완화 +- 동시 fetch 부분 실패 → UI 폴백으로 완화 +- OAuth 환경 드리프트 → debug 로그로 완화 diff --git a/src/commands/skill/__tests__/preparation.test.ts b/src/commands/skill/__tests__/preparation.test.ts index b143976..b4162e4 100644 --- a/src/commands/skill/__tests__/preparation.test.ts +++ b/src/commands/skill/__tests__/preparation.test.ts @@ -6,11 +6,17 @@ import { checkFirebaseStatus, checkIosStatus } from '../preparation'; const mockFirebaseService = { detect: mock(() => Promise.resolve({ - platform: 'react-native', - android: { valid: true, content: { project_info: { project_id: 'test-project' } } }, - ios: { valid: true, content: { PROJECT_ID: 'test-project' } }, + platform: 'react-native' as string, + android: { valid: true, content: { project_info: { project_id: 'test-project' } } } as { + valid: boolean; + content: Record; + } | null, + ios: { valid: true, content: { PROJECT_ID: 'test-project' } } as { + valid: boolean; + content: Record; + } | null, configured: true, - issues: [], + issues: [] as unknown[], projectPath: '/test', }), ), @@ -97,7 +103,7 @@ describe('preparation', () => { expect(status.configured).toBe(true); }); - test('should use setup status from config if already configured', async () => { + test('should always detect files even if setup config exists', async () => { const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; const setup = { firebase: { @@ -109,10 +115,36 @@ describe('preparation', () => { const status = await checkFirebaseStatus('/test', projectType, setup); + // Should always run file detection regardless of cached setup + expect(mockFirebaseService.detect).toHaveBeenCalled(); + expect(mockFirebaseService.getStatus).toHaveBeenCalled(); expect(status.configured).toBe(true); + // Project ID from detected files takes precedence over cached config + expect(status.projectId).toBe('test-project'); + }); + + test('should fallback to cached projectId when files have no project ID', async () => { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + const setup = { + firebase: { + projectId: 'my-project', + androidConfigured: true, + iosConfigured: true, + }, + }; + + mockFirebaseService.detect.mockResolvedValueOnce({ + platform: 'react-native', + android: null, + ios: null, + configured: true, + issues: [], + projectPath: '/test', + }); + + const status = await checkFirebaseStatus('/test', projectType, setup); + expect(status.projectId).toBe('my-project'); - expect(status.androidConfigured).toBe(true); - expect(status.iosConfigured).toBe(true); }); test('should detect Firebase config when not in setup', async () => { diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts index 31d0b39..615d3e5 100644 --- a/src/commands/skill/preparation.ts +++ b/src/commands/skill/preparation.ts @@ -168,18 +168,7 @@ export async function checkFirebaseStatus( }; } - // Check if already marked as configured in setup - if (setup?.firebase?.androidConfigured && setup?.firebase?.iosConfigured) { - return { - configured: true, - androidConfigured: true, - iosConfigured: true, - projectId: setup.firebase.projectId, - needed: true, - }; - } - - // Detect Firebase config files + // Always detect actual Firebase config files on disk const firebaseService = new FirebaseService(projectPath, projectType); const detection = await firebaseService.detect(); const status = await firebaseService.getStatus(); @@ -191,12 +180,14 @@ export async function checkFirebaseStatus( const androidConfigured = !needsAndroid || status.androidConfigured; const iosConfigured = !needsIos || status.iosConfigured; - // Extract project ID if available + // Extract project ID from detected files, fallback to cached setup let projectId: string | undefined; if (detection.android?.content && 'project_info' in detection.android.content) { projectId = detection.android.content.project_info?.project_id; } else if (detection.ios?.content && 'PROJECT_ID' in detection.ios.content) { projectId = detection.ios.content.PROJECT_ID; + } else if (setup?.firebase?.projectId) { + projectId = setup.firebase.projectId; } return { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5ea9482..0bca7b7 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -10,7 +10,9 @@ export type { ApiErrorResponse, ApiListResponse, ApiResponse, + AppPushSenderConfig, Member, Organization, Project, + SenderConfig, } from './types'; diff --git a/src/lib/api/internal-client.ts b/src/lib/api/internal-client.ts index f623033..d3ee91f 100644 --- a/src/lib/api/internal-client.ts +++ b/src/lib/api/internal-client.ts @@ -1,12 +1,19 @@ import { getConsoleUrl, getCredentialsManager } from '../auth'; import { AuthError } from '../auth/errors'; import { NetworkError } from '../errors/types'; -import type { Member, Organization, Project } from './types'; +import type { Member, Organization, Project, SenderConfig } from './types'; /** * Internal API proxy prefix on Console. */ const API_PROXY_PREFIX = '/api/clix/internal'; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +interface InternalApiRequestOptions { + authToken?: string; + timeoutMs?: number; + maxRetries?: number; +} /** * Internal API client that communicates through Console's proxy endpoint. @@ -29,6 +36,24 @@ export class InternalApiClient { this.baseUrl = (consoleUrl ?? getConsoleUrl()) + API_PROXY_PREFIX; } + private async resolveAccessToken(authToken?: string): Promise { + if (authToken) return authToken; + + const credentialsManager = getCredentialsManager(); + const token = await credentialsManager.getValidToken(); + if (!token) { + throw AuthError.notLoggedIn(); + } + return token; + } + + private shouldRetry(error: unknown): boolean { + if (!(error instanceof NetworkError)) return false; + if (error.statusCode === 429) return true; + if (typeof error.statusCode === 'number' && error.statusCode >= 500) return true; + return error.statusCode === undefined; + } + /** * Make an authenticated request to Internal API. * @@ -38,47 +63,64 @@ export class InternalApiClient { * @throws AuthError if not authenticated * @throws NetworkError if request fails */ - private async request(endpoint: string, options: RequestInit = {}): Promise { - const credentialsManager = getCredentialsManager(); - const token = await credentialsManager.getValidToken(); - - if (!token) { - throw AuthError.notLoggedIn(); - } - + private async request( + endpoint: string, + options: RequestInit = {}, + requestOptions: InternalApiRequestOptions = {}, + ): Promise { + const token = await this.resolveAccessToken(requestOptions.authToken); + const timeoutMs = requestOptions.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const maxRetries = requestOptions.maxRetries ?? 0; const url = `${this.baseUrl}${endpoint}`; - const headers = new Headers(options.headers); - headers.set('Authorization', `Bearer ${token}`); - headers.set('Content-Type', 'application/json'); - - try { - const response = await fetch(url, { - ...options, - headers, - }); - - if (!response.ok) { - if (response.status === 401) { - throw AuthError.tokenExpired(); + let attempt = 0; + + while (true) { + const headers = new Headers(options.headers); + headers.set('Authorization', `Bearer ${token}`); + headers.set('Content-Type', 'application/json'); + + try { + const response = await fetch(url, { + ...options, + headers, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) { + if (response.status === 401) { + throw AuthError.tokenExpired(); + } + + const errorText = await response.text().catch(() => ''); + throw new NetworkError( + `API request failed: ${response.status} ${errorText}`, + url, + response.status, + ); } - const errorText = await response.text().catch(() => ''); - throw new NetworkError( - `API request failed: ${response.status} ${errorText}`, - url, - response.status, - ); - } + return response.json() as Promise; + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + + const normalizedError = + error instanceof NetworkError + ? error + : new NetworkError( + `Failed to connect to API: ${error instanceof Error ? error.message : 'Unknown error'}`, + url, + ); + + if (attempt >= maxRetries || !this.shouldRetry(normalizedError)) { + throw normalizedError; + } - return response.json() as Promise; - } catch (error) { - if (error instanceof AuthError || error instanceof NetworkError) { - throw error; + attempt += 1; + const backoffMs = 150 * 2 ** (attempt - 1); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); } - throw new NetworkError( - `Failed to connect to API: ${error instanceof Error ? error.message : 'Unknown error'}`, - url, - ); } } @@ -97,8 +139,12 @@ export class InternalApiClient { * * @returns List of organizations */ - async listOrganizations(): Promise { - const response = await this.request<{ organizations: Organization[] }>('/api/v1/organizations'); + async listOrganizations(options?: InternalApiRequestOptions): Promise { + const response = await this.request<{ organizations: Organization[] }>( + '/api/v1/organizations', + {}, + options, + ); return response.organizations; } @@ -108,12 +154,55 @@ export class InternalApiClient { * @param organizationId - Organization ID * @returns List of projects */ - async listProjects(organizationId: string): Promise { + async listProjects( + organizationId: string, + options?: InternalApiRequestOptions, + ): Promise { const response = await this.request<{ projects: Project[] }>( `/api/v1/organizations/${organizationId}/projects`, + {}, + options, ); return response.projects; } + + /** + * Get project details including sender configs. + * + * @param projectId - Project ID + * @returns Project with sender_configs + */ + async getProject(projectId: string, options?: InternalApiRequestOptions): Promise { + const response = await this.request<{ project: Project }>( + `/api/v1/projects/${projectId}`, + {}, + options, + ); + return response.project; + } + + /** + * Create or update sender config for push notifications. + * + * @param projectId - Project ID + * @param senderConfig - Sender configuration + * @returns Updated sender config + */ + async createOrUpdateSenderConfig( + projectId: string, + senderConfig: SenderConfig, + options?: InternalApiRequestOptions, + ): Promise { + const response = await this.request<{ sender_config: SenderConfig }>( + `/api/v1/projects/${projectId}/sender-configs`, + { + method: 'POST', + body: JSON.stringify({ sender_config: senderConfig }), + }, + options, + ); + return response.sender_config; + } } /** diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 341415a..bb62ecb 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -29,6 +29,25 @@ export interface Project { name: string; organization_id: string; public_key?: string; + sender_configs?: SenderConfig[]; + created_at?: string; + updated_at?: string; +} + +/** + * App push sender configuration for FCM. + */ +export interface AppPushSenderConfig { + ios_config?: { fcm_sa_json_base64_encoded: string }; + android_config?: { fcm_sa_json_base64_encoded: string }; +} + +/** + * Sender configuration for push notifications. + */ +export interface SenderConfig { + channel_type: string; + app_push?: AppPushSenderConfig; created_at?: string; updated_at?: string; } diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index 1dd842e..c86bedb 100644 --- a/src/lib/auth/credentials.ts +++ b/src/lib/auth/credentials.ts @@ -362,16 +362,13 @@ export class CredentialsManager { /** * Clear only Firebase tokens (keep Clix credentials). + * Does not delete the credentials file, only removes the firebase field. */ async clearFirebaseTokens(): Promise { const current = await this.load(); - if (current) { + if (current?.firebase) { const { firebase: _, ...rest } = current; - if (rest.clix) { - await this.save({ ...rest, version: CREDENTIALS_VERSION }); - } else { - await this.delete(); - } + await this.save({ ...rest, version: CREDENTIALS_VERSION }); } } diff --git a/src/lib/debug/logger.ts b/src/lib/debug/logger.ts index db0a46a..5920c4c 100644 --- a/src/lib/debug/logger.ts +++ b/src/lib/debug/logger.ts @@ -7,6 +7,8 @@ * - DEBUG=config - enable config-related debug output * - DEBUG=* - enable all debug output * + * Debug logs are also written to `.clix/debug.log` file (npm-style). + * * @example * ```typescript * import { logger } from '../debug/logger'; @@ -18,6 +20,9 @@ * ``` */ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export interface LogContext { @@ -184,6 +189,41 @@ export class Logger { throw error; } } + + /** + * Write debug info to .clix/debug.log file (npm-style). + * Always writes regardless of DEBUG environment variable. + * + * @param message - Log message + * @param data - Additional data to log + * @param projectRoot - Project root directory (optional, uses cwd if not provided) + */ + writeToFile(message: string, data?: unknown, projectRoot?: string): void { + // Try multiple locations to ensure logging works + const locations = [ + projectRoot, + process.cwd(), + process.env.HOME ? join(process.env.HOME, '.clix') : null, + ].filter((loc): loc is string => loc !== null && loc !== undefined); + + for (const root of locations) { + try { + const clixDir = root.endsWith('.clix') ? root : join(root, '.clix'); + const logFile = join(clixDir, 'debug.log'); + + mkdirSync(clixDir, { recursive: true }); + + const timestamp = new Date().toISOString(); + const line = `${timestamp} ${this.namespace} ${message}${data !== undefined ? ` ${JSON.stringify(data)}` : ''}\n`; + appendFileSync(logFile, line); + return; // Success, exit loop + } catch { + // Try next location + } + } + // All locations failed, log to stderr as last resort + console.error(`[${this.namespace}] ${message}`, data !== undefined ? JSON.stringify(data) : ''); + } } /** @@ -197,3 +237,8 @@ export const logger = new Logger('clix'); export function createLogger(namespace: string): Logger { return new Logger(namespace); } + +/** + * OAuth logger for debugging authentication flows. + */ +export const oauthLogger = createLogger('oauth'); diff --git a/src/lib/services/__tests__/organization-projects.test.ts b/src/lib/services/__tests__/organization-projects.test.ts new file mode 100644 index 0000000..049c964 --- /dev/null +++ b/src/lib/services/__tests__/organization-projects.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Organization, Project } from '@/lib/api'; + +const listOrganizationsMock = mock(async (_options?: unknown): Promise => []); +const listProjectsMock = mock(async (_orgId: string, _options?: unknown): Promise => []); +const getValidTokenMock = mock(async (): Promise => 'token-1'); + +mock.module('@/lib/api', () => ({ + getInternalApiClient: () => ({ + listOrganizations: listOrganizationsMock, + listProjects: listProjectsMock, + }), +})); + +mock.module('@/lib/auth', () => ({ + getCredentialsManager: () => ({ + getValidToken: getValidTokenMock, + }), +})); + +import { fetchOrganizationsWithProjects } from '../organization-projects'; + +function org(id: string, name: string): Organization { + return { id, name }; +} + +function project(id: string, name: string, organizationId: string): Project { + return { id, name, organization_id: organizationId }; +} + +describe('fetchOrganizationsWithProjects', () => { + beforeEach(() => { + listOrganizationsMock.mockReset(); + listProjectsMock.mockReset(); + getValidTokenMock.mockReset(); + getValidTokenMock.mockResolvedValue('token-1'); + }); + + test('returns empty list when token is unavailable', async () => { + getValidTokenMock.mockResolvedValue(null); + + const result = await fetchOrganizationsWithProjects(); + + expect(result).toEqual([]); + expect(listOrganizationsMock).not.toHaveBeenCalled(); + }); + + test('passes timeout/retry/auth options to api client', async () => { + listOrganizationsMock.mockResolvedValue([org('o1', 'Org 1')]); + listProjectsMock.mockResolvedValue([project('p1', 'Project 1', 'o1')]); + + const result = await fetchOrganizationsWithProjects({ + requestTimeoutMs: 4500, + maxRetries: 2, + projectFetchConcurrency: 3, + }); + + expect(result).toHaveLength(1); + expect(listOrganizationsMock).toHaveBeenCalledTimes(1); + expect(listProjectsMock).toHaveBeenCalledTimes(1); + + const orgCallOptions = listOrganizationsMock.mock.calls[0]?.[0] as { + authToken: string; + timeoutMs: number; + maxRetries: number; + }; + expect(orgCallOptions.authToken).toBe('token-1'); + expect(orgCallOptions.timeoutMs).toBe(4500); + expect(orgCallOptions.maxRetries).toBe(2); + + const projectCallOptions = listProjectsMock.mock.calls[0]?.[1] as { + authToken: string; + timeoutMs: number; + maxRetries: number; + }; + expect(projectCallOptions.authToken).toBe('token-1'); + expect(projectCallOptions.timeoutMs).toBe(4500); + expect(projectCallOptions.maxRetries).toBe(2); + }); + + test('limits concurrent project fetches and tolerates per-org failure', async () => { + listOrganizationsMock.mockResolvedValue([ + org('o1', 'Org 1'), + org('o2', 'Org 2'), + org('o3', 'Org 3'), + org('o4', 'Org 4'), + ]); + + let inFlight = 0; + let maxInFlight = 0; + + listProjectsMock.mockImplementation(async (organizationId: string): Promise => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + + if (organizationId === 'o3') { + throw new Error('temporary failure'); + } + + return [project(`p-${organizationId}`, `Project ${organizationId}`, organizationId)]; + }); + + const result = await fetchOrganizationsWithProjects({ projectFetchConcurrency: 2 }); + + expect(maxInFlight).toBeLessThanOrEqual(2); + expect(result).toHaveLength(4); + + const failedOrg = result.find((item) => item.org.id === 'o3'); + expect(failedOrg?.projects).toEqual([]); + }); +}); diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index 98aceac..ab7c363 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -10,6 +10,8 @@ import { } from '@googleapis/cloudresourcemanager'; import { firebase, type firebase_v1beta1 } from '@googleapis/firebase'; import { OAuth2Client } from 'google-auth-library'; +import { oauthLogger } from '@/lib/debug/logger'; +import { findProjectRoot } from '@/lib/utils/path'; import type { AndroidApp, CreateAndroidAppRequest, @@ -22,88 +24,150 @@ import type { type FirebaseApi = firebase_v1beta1.Firebase; type ResourceManagerApi = cloudresourcemanager_v1.Cloudresourcemanager; +/** + * Log API errors to debug.log for troubleshooting. + */ +function logApiError(operation: string, error: unknown): void { + const errorInfo = { + type: 'firebase_api_error', + operation, + message: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : undefined, + // Extract Google API error details if available + code: (error as { code?: number | string })?.code, + errors: (error as { errors?: unknown[] })?.errors, + response: (error as { response?: { data?: unknown } })?.response?.data, + }; + oauthLogger.writeToFile(`Firebase API error: ${operation}`, errorInfo, findProjectRoot()); +} + +export interface ApiClientCredentials { + clientId: string; + clientSecret: string; +} + export class FirebaseApiClient { private fb: FirebaseApi; private rm: ResourceManagerApi; + private auth: OAuth2Client; + private getAccessTokenFn: () => Promise; + + constructor(getAccessToken: () => Promise, credentials?: ApiClientCredentials) { + this.getAccessTokenFn = getAccessToken; - constructor(getAccessToken: () => Promise) { - const auth = new OAuth2Client(); - auth.getAccessToken = async () => ({ token: await getAccessToken() }); + // Create OAuth2Client with credentials if provided + this.auth = credentials + ? new OAuth2Client({ + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + }) + : new OAuth2Client(); - this.fb = firebase({ version: 'v1beta1', auth }); - this.rm = cloudresourcemanager({ version: 'v1', auth }); + this.fb = firebase({ version: 'v1beta1', auth: this.auth }); + this.rm = cloudresourcemanager({ version: 'v1', auth: this.auth }); + } + + /** + * Update OAuth2Client credentials with current access token. + * Must be called before each API request. + */ + private async updateCredentials(): Promise { + const token = await this.getAccessTokenFn(); + this.auth.setCredentials({ + access_token: token, + token_type: 'Bearer', + }); } async listProjects(): Promise { - const projects: FirebaseProject[] = []; - let pageToken: string | undefined; - - do { - const res = await this.fb.projects.list({ pageToken }); - for (const p of res.data.results ?? []) { - projects.push({ - name: p.name ?? '', - projectId: p.projectId ?? '', - projectNumber: p.projectNumber ?? '', - displayName: p.displayName ?? '', - state: (p.state as 'ACTIVE' | 'DELETED') ?? 'ACTIVE', - }); - } - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); + try { + await this.updateCredentials(); + const projects: FirebaseProject[] = []; + let pageToken: string | undefined; + + do { + const res = await this.fb.projects.list({ pageToken }); + for (const p of res.data.results ?? []) { + projects.push({ + name: p.name ?? '', + projectId: p.projectId ?? '', + projectNumber: p.projectNumber ?? '', + displayName: p.displayName ?? '', + state: (p.state as 'ACTIVE' | 'DELETED') ?? 'ACTIVE', + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - return projects; + return projects; + } catch (error) { + logApiError('listProjects', error); + throw error; + } } async listAndroidApps(projectId: string): Promise { - const apps: AndroidApp[] = []; - let pageToken: string | undefined; - - do { - const res = await this.fb.projects.androidApps.list({ - parent: `projects/${projectId}`, - pageToken, - }); - for (const a of res.data.apps ?? []) { - apps.push({ - name: a.name ?? '', - appId: a.appId ?? '', - displayName: a.displayName ?? undefined, - packageName: a.packageName ?? '', - projectId: a.projectId ?? projectId, + try { + await this.updateCredentials(); + const apps: AndroidApp[] = []; + let pageToken: string | undefined; + + do { + const res = await this.fb.projects.androidApps.list({ + parent: `projects/${projectId}`, + pageToken, }); - } - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); + for (const a of res.data.apps ?? []) { + apps.push({ + name: a.name ?? '', + appId: a.appId ?? '', + displayName: a.displayName ?? undefined, + packageName: a.packageName ?? '', + projectId: a.projectId ?? projectId, + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - return apps; + return apps; + } catch (error) { + logApiError('listAndroidApps', error); + throw error; + } } async listIosApps(projectId: string): Promise { - const apps: IosApp[] = []; - let pageToken: string | undefined; - - do { - const res = await this.fb.projects.iosApps.list({ - parent: `projects/${projectId}`, - pageToken, - }); - for (const a of res.data.apps ?? []) { - apps.push({ - name: a.name ?? '', - appId: a.appId ?? '', - displayName: a.displayName ?? undefined, - bundleId: a.bundleId ?? '', - projectId: a.projectId ?? projectId, + try { + await this.updateCredentials(); + const apps: IosApp[] = []; + let pageToken: string | undefined; + + do { + const res = await this.fb.projects.iosApps.list({ + parent: `projects/${projectId}`, + pageToken, }); - } - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); + for (const a of res.data.apps ?? []) { + apps.push({ + name: a.name ?? '', + appId: a.appId ?? '', + displayName: a.displayName ?? undefined, + bundleId: a.bundleId ?? '', + projectId: a.projectId ?? projectId, + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - return apps; + return apps; + } catch (error) { + logApiError('listIosApps', error); + throw error; + } } async getAndroidConfig(projectId: string, appId: string): Promise { + await this.updateCredentials(); const res = await this.fb.projects.androidApps.getConfig({ name: `projects/${projectId}/androidApps/${appId}/config`, }); @@ -111,6 +175,7 @@ export class FirebaseApiClient { } async getIosConfig(projectId: string, appId: string): Promise { + await this.updateCredentials(); const res = await this.fb.projects.iosApps.getConfig({ name: `projects/${projectId}/iosApps/${appId}/config`, }); @@ -118,6 +183,7 @@ export class FirebaseApiClient { } async createAndroidApp(projectId: string, request: CreateAndroidAppRequest): Promise { + await this.updateCredentials(); const res = await this.fb.projects.androidApps.create({ parent: `projects/${projectId}`, requestBody: request, @@ -137,6 +203,7 @@ export class FirebaseApiClient { } async createIosApp(projectId: string, request: CreateIosAppRequest): Promise { + await this.updateCredentials(); const res = await this.fb.projects.iosApps.create({ parent: `projects/${projectId}`, requestBody: request, @@ -156,24 +223,30 @@ export class FirebaseApiClient { } async listGcpProjects(): Promise { - const projects: GcpProject[] = []; - let pageToken: string | undefined; - - do { - const res = await this.rm.projects.list({ pageToken }); - for (const p of res.data.projects ?? []) { - projects.push({ - projectId: p.projectId ?? '', - name: p.name ?? '', - projectNumber: p.projectNumber ?? '', - lifecycleState: (p.lifecycleState as GcpProject['lifecycleState']) ?? 'ACTIVE', - createTime: p.createTime ?? undefined, - }); - } - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); + try { + await this.updateCredentials(); + const projects: GcpProject[] = []; + let pageToken: string | undefined; - return projects; + do { + const res = await this.rm.projects.list({ pageToken }); + for (const p of res.data.projects ?? []) { + projects.push({ + projectId: p.projectId ?? '', + name: p.name ?? '', + projectNumber: p.projectNumber ?? '', + lifecycleState: (p.lifecycleState as GcpProject['lifecycleState']) ?? 'ACTIVE', + createTime: p.createTime ?? undefined, + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + return projects; + } catch (error) { + logApiError('listGcpProjects', error); + throw error; + } } async listAvailableGcpProjects(): Promise { @@ -188,6 +261,7 @@ export class FirebaseApiClient { } async addFirebaseToProject(projectId: string): Promise { + await this.updateCredentials(); const res = await this.fb.projects.addFirebase({ project: `projects/${projectId}` }); if (!res.data.name) { throw new Error('Failed to add Firebase: no operation name returned'); @@ -208,6 +282,7 @@ export class FirebaseApiClient { const startTime = Date.now(); while (Date.now() - startTime < maxWaitMs) { + await this.updateCredentials(); const res = await this.fb.operations.get({ name: operationName }); if (res.data.done) { if (res.data.error) { diff --git a/src/lib/services/firebase/api/iam-api.ts b/src/lib/services/firebase/api/iam-api.ts deleted file mode 100644 index 285fb69..0000000 --- a/src/lib/services/firebase/api/iam-api.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Google IAM API client using @googleapis/iam. - * - * @module services/firebase/api/iam-api - */ - -import { iam, type iam_v1 } from '@googleapis/iam'; -import { OAuth2Client } from 'google-auth-library'; -import type { ServiceAccount, ServiceAccountKey } from './types'; - -type IamApi = iam_v1.Iam; - -export class IamApiClient { - private api: IamApi; - - constructor(getAccessToken: () => Promise) { - const auth = new OAuth2Client(); - auth.getAccessToken = async () => ({ token: await getAccessToken() }); - - this.api = iam({ version: 'v1', auth }); - } - - async listServiceAccounts(projectId: string): Promise { - const accounts: ServiceAccount[] = []; - let pageToken: string | undefined; - - do { - const res = await this.api.projects.serviceAccounts.list({ - name: `projects/${projectId}`, - pageToken, - }); - for (const sa of res.data.accounts ?? []) { - accounts.push({ - name: sa.name ?? '', - projectId: sa.projectId ?? projectId, - uniqueId: sa.uniqueId ?? '', - email: sa.email ?? '', - displayName: sa.displayName ?? undefined, - description: sa.description ?? undefined, - disabled: sa.disabled ?? false, - }); - } - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); - - return accounts; - } - - async getServiceAccount(projectId: string, serviceAccountEmail: string): Promise { - const res = await this.api.projects.serviceAccounts.get({ - name: `projects/${projectId}/serviceAccounts/${serviceAccountEmail}`, - }); - const sa = res.data; - return { - name: sa.name ?? '', - projectId: sa.projectId ?? projectId, - uniqueId: sa.uniqueId ?? '', - email: sa.email ?? '', - displayName: sa.displayName ?? undefined, - description: sa.description ?? undefined, - disabled: sa.disabled ?? false, - }; - } - - async createServiceAccount( - projectId: string, - accountId: string, - displayName?: string, - description?: string, - ): Promise { - const res = await this.api.projects.serviceAccounts.create({ - name: `projects/${projectId}`, - requestBody: { - accountId, - serviceAccount: { displayName, description }, - }, - }); - const sa = res.data; - return { - name: sa.name ?? '', - projectId: sa.projectId ?? projectId, - uniqueId: sa.uniqueId ?? '', - email: sa.email ?? '', - displayName: sa.displayName ?? undefined, - description: sa.description ?? undefined, - disabled: sa.disabled ?? false, - }; - } - - async createServiceAccountKey( - projectId: string, - serviceAccountEmail: string, - ): Promise { - const res = await this.api.projects.serviceAccounts.keys.create({ - name: `projects/${projectId}/serviceAccounts/${serviceAccountEmail}`, - requestBody: { - privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE', - keyAlgorithm: 'KEY_ALG_RSA_2048', - }, - }); - const key = res.data; - return { - name: key.name ?? '', - privateKeyType: key.privateKeyType ?? '', - keyAlgorithm: key.keyAlgorithm ?? '', - privateKeyData: key.privateKeyData ?? '', - validAfterTime: key.validAfterTime ?? '', - validBeforeTime: key.validBeforeTime ?? '', - }; - } - - async createServiceAccountWithKey( - projectId: string, - accountId: string, - displayName?: string, - ): Promise<{ serviceAccount: ServiceAccount; key: ServiceAccountKey }> { - const serviceAccount = await this.createServiceAccount( - projectId, - accountId, - displayName, - 'Created by Clix CLI for Firebase Admin SDK', - ); - const key = await this.createServiceAccountKey(projectId, serviceAccount.email); - return { serviceAccount, key }; - } -} diff --git a/src/lib/services/firebase/api/index.ts b/src/lib/services/firebase/api/index.ts index 7decd1b..a779e6a 100644 --- a/src/lib/services/firebase/api/index.ts +++ b/src/lib/services/firebase/api/index.ts @@ -4,8 +4,7 @@ * @module services/firebase/api */ -export { FirebaseApiClient } from './firebase-api'; -export { IamApiClient } from './iam-api'; +export { type ApiClientCredentials, FirebaseApiClient } from './firebase-api'; export type { AndroidApp, CreateAndroidAppRequest, @@ -13,7 +12,5 @@ export type { FirebaseProject, GcpProject, IosApp, - ServiceAccount, ServiceAccountJson, - ServiceAccountKey, } from './types'; diff --git a/src/lib/services/firebase/api/types.ts b/src/lib/services/firebase/api/types.ts index ff80420..eeba5cc 100644 --- a/src/lib/services/firebase/api/types.ts +++ b/src/lib/services/firebase/api/types.ts @@ -46,25 +46,6 @@ export interface GcpProject { createTime?: string; } -export interface ServiceAccount { - name: string; - projectId: string; - uniqueId: string; - email: string; - displayName?: string; - description?: string; - disabled?: boolean; -} - -export interface ServiceAccountKey { - name: string; - privateKeyType: string; - keyAlgorithm: string; - privateKeyData: string; - validAfterTime: string; - validBeforeTime: string; -} - export interface ServiceAccountJson { type: 'service_account'; project_id: string; diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index 539fbcf..6f03363 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -16,14 +16,12 @@ import type { FirebaseProject, GcpProject, IosApp, - ServiceAccount, ServiceAccountJson, } from './api'; -import { FirebaseApiClient, IamApiClient } from './api'; +import { type ApiClientCredentials, FirebaseApiClient } from './api'; import { getExpectedPaths } from './detector'; -import { GoogleAuthClient } from './oauth'; +import { GoogleAuthClient, getOAuthCredentials } from './oauth'; import { - parseBase64ServiceAccountJson, parseServiceAccountJson, type ServiceAccountValidationResult, } from './service-account-validator'; @@ -70,7 +68,7 @@ export interface DownloadResult { export class FirebaseDownloader { private authClient: GoogleAuthClient; private apiClient: FirebaseApiClient | null = null; - private iamClient: IamApiClient | null = null; + private credentials: ApiClientCredentials | null = null; constructor() { this.authClient = new GoogleAuthClient(); @@ -101,7 +99,15 @@ export class FirebaseDownloader { ): Promise<{ success: boolean; error?: string }> { const result = await this.authClient.authenticate(openBrowser); if (result.success) { - this.apiClient = new FirebaseApiClient(() => this.authClient.getAccessToken()); + // Fetch and store credentials for API clients + const creds = await getOAuthCredentials(); + if (creds) { + this.credentials = { clientId: creds.clientId, clientSecret: creds.clientSecret }; + } + this.apiClient = new FirebaseApiClient( + () => this.authClient.getAccessToken(), + this.credentials ?? undefined, + ); } return result; } @@ -109,9 +115,19 @@ export class FirebaseDownloader { /** * Ensure API client is initialized. */ - private ensureApiClient(): FirebaseApiClient { + private async ensureApiClient(): Promise { if (!this.apiClient) { - this.apiClient = new FirebaseApiClient(() => this.authClient.getAccessToken()); + // Fetch credentials if not already cached + if (!this.credentials) { + const creds = await getOAuthCredentials(); + if (creds) { + this.credentials = { clientId: creds.clientId, clientSecret: creds.clientSecret }; + } + } + this.apiClient = new FirebaseApiClient( + () => this.authClient.getAccessToken(), + this.credentials ?? undefined, + ); } return this.apiClient; } @@ -123,7 +139,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.listProjects(); } @@ -134,7 +150,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.listAndroidApps(projectId); } @@ -145,7 +161,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.listIosApps(projectId); } @@ -174,7 +190,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.createAndroidApp(projectId, request); } @@ -189,7 +205,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.createIosApp(projectId, request); } @@ -204,7 +220,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); const config = await api.getAndroidConfig(projectId, appId); const dir = path.dirname(savePath); @@ -223,7 +239,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); const config = await api.getIosConfig(projectId, appId); const dir = path.dirname(savePath); @@ -279,7 +295,6 @@ export class FirebaseDownloader { async logout(): Promise { await this.authClient.logout(); this.apiClient = null; - this.iamClient = null; } // ============================================================================ @@ -295,7 +310,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.listAvailableGcpProjects(); } @@ -309,7 +324,7 @@ export class FirebaseDownloader { if (!(await this.isAuthenticated())) { throw new Error('Not authenticated. Run OAuth flow first.'); } - const api = this.ensureApiClient(); + const api = await this.ensureApiClient(); return api.addFirebaseToProject(projectId); } @@ -317,86 +332,6 @@ export class FirebaseDownloader { // Service Account Management // ============================================================================ - /** - * Ensure IAM client is initialized. - */ - private ensureIamClient(): IamApiClient { - if (!this.iamClient) { - this.iamClient = new IamApiClient(() => this.authClient.getAccessToken()); - } - return this.iamClient; - } - - /** - * List service accounts in a project. - * - * @param projectId - GCP project ID - * @returns List of service accounts - */ - async listServiceAccounts(projectId: string): Promise { - if (!(await this.isAuthenticated())) { - throw new Error('Not authenticated. Run OAuth flow first.'); - } - const iam = this.ensureIamClient(); - return iam.listServiceAccounts(projectId); - } - - /** - * Create a service account and generate a key. - * - * @param projectId - GCP project ID - * @param accountId - Service account ID (e.g., "clix-firebase-admin") - * @param displayName - Optional display name - * @returns Service Account JSON key - */ - async createServiceAccountWithKey( - projectId: string, - accountId: string, - displayName?: string, - ): Promise { - if (!(await this.isAuthenticated())) { - throw new Error('Not authenticated. Run OAuth flow first.'); - } - const iam = this.ensureIamClient(); - - const { key } = await iam.createServiceAccountWithKey(projectId, accountId, displayName); - - // Decode base64 key data - const result = parseBase64ServiceAccountJson(key.privateKeyData); - if (!result.valid || !result.data) { - throw new Error(`Invalid service account key data: ${result.errors.join(', ')}`); - } - - return result.data; - } - - /** - * Download a new key for an existing service account. - * - * @param projectId - GCP project ID - * @param serviceAccountEmail - Service account email - * @returns Service Account JSON key - */ - async downloadServiceAccountKey( - projectId: string, - serviceAccountEmail: string, - ): Promise { - if (!(await this.isAuthenticated())) { - throw new Error('Not authenticated. Run OAuth flow first.'); - } - const iam = this.ensureIamClient(); - - const key = await iam.createServiceAccountKey(projectId, serviceAccountEmail); - - // Decode base64 key data - const result = parseBase64ServiceAccountJson(key.privateKeyData); - if (!result.valid || !result.data) { - throw new Error(`Invalid service account key data: ${result.errors.join(', ')}`); - } - - return result.data; - } - /** * Validate a Service Account JSON string. * diff --git a/src/lib/services/firebase/index.ts b/src/lib/services/firebase/index.ts index d89f544..90d7158 100644 --- a/src/lib/services/firebase/index.ts +++ b/src/lib/services/firebase/index.ts @@ -12,10 +12,8 @@ export type { FirebaseProject, GcpProject, IosApp, - ServiceAccount, ServiceAccountJson, } from './api'; -export { IamApiClient } from './api'; // Detection and validation export { detectFirebaseConfig, getExpectedPaths } from './detector'; diff --git a/src/lib/services/firebase/oauth/auth-client.ts b/src/lib/services/firebase/oauth/auth-client.ts index b58b24c..abbac4e 100644 --- a/src/lib/services/firebase/oauth/auth-client.ts +++ b/src/lib/services/firebase/oauth/auth-client.ts @@ -7,6 +7,7 @@ * @module services/firebase/oauth/auth-client */ +import { oauthLogger } from '@/lib/debug/logger'; import { generateCodeChallenge, generateCodeVerifier, @@ -14,6 +15,7 @@ import { OAUTH_CALLBACK_CONFIG, OAuthCallbackServer, } from '@/lib/utils/oauth'; +import { findProjectRoot } from '@/lib/utils/path'; import { GOOGLE_OAUTH_CONFIG, getOAuthCredentials, isOAuthConfigured } from './config'; import { TokenStore } from './token-store'; import type { AuthResult, OAuthCallbackResult, OAuthTokens } from './types'; @@ -90,13 +92,19 @@ export class GoogleAuthClient { const authUrl = `${GOOGLE_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}`; - // Debug: Log PKCE parameters - if (process.env.DEBUG) { - console.error('[OAuth Debug] Authorization request:'); - console.error(' code_challenge:', codeChallenge); - console.error(' code_challenge_method: S256'); - console.error(' PKCE enabled: true'); - } + // Always log OAuth request parameters to debug.log + oauthLogger.writeToFile( + 'OAuth authorization URL generated', + { + type: 'auth_url_generated', + client_id: `${clientId.substring(0, 20)}...`, + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, + scopes: GOOGLE_OAUTH_CONFIG.scopes, + has_code_challenge: !!codeChallenge, + has_state: !!this.oauthState, + }, + findProjectRoot(), + ); return authUrl; } @@ -179,10 +187,22 @@ export class GoogleAuthClient { if (!response.ok) { const errorData = await response.json().catch(() => ({})); - // Debug: Log error response - if (process.env.DEBUG) { - console.error('[OAuth Debug] Token exchange error:', JSON.stringify(errorData, null, 2)); - } + // Write debug info to .clix/debug.log + oauthLogger.writeToFile( + 'Token exchange failed', + { + type: 'token_exchange_error', + error: errorData, + params: { + client_id: `${clientId.substring(0, 20)}...`, + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, + has_code: !!code, + has_code_verifier: !!this.codeVerifier, + has_client_secret: !!clientSecret, + }, + }, + findProjectRoot(), + ); const errorMessage = (errorData as { error_description?: string; error?: string }).error_description || @@ -246,9 +266,36 @@ export class GoogleAuthClient { if (!response.ok) { const errorData = await response.json().catch(() => ({})); + const errorCode = (errorData as { error?: string }).error; + + // Write debug info to .clix/debug.log + oauthLogger.writeToFile( + 'Token refresh failed', + { + type: 'token_refresh_error', + error: errorData, + params: { + client_id: `${clientId.substring(0, 20)}...`, + has_refresh_token: !!refreshToken, + has_client_secret: !!clientSecret, + }, + }, + findProjectRoot(), + ); + + // If invalid_grant, clear stored tokens so user can re-authenticate + if (errorCode === 'invalid_grant') { + oauthLogger.writeToFile( + 'Clearing stored tokens due to invalid_grant', + { type: 'tokens_cleared' }, + findProjectRoot(), + ); + await this.tokenStore.clear(); + } + const errorMessage = (errorData as { error_description?: string; error?: string }).error_description || - (errorData as { error?: string }).error || + errorCode || `Token refresh failed: ${response.status}`; throw new Error(errorMessage); } @@ -277,6 +324,7 @@ export class GoogleAuthClient { * Automatically refreshes if expired. * * @returns Access token string + * @throws Error with 'invalid_grant' if tokens are invalid and need re-authentication */ async getAccessToken(): Promise { let tokens = await this.tokenStore.load(); @@ -287,7 +335,14 @@ export class GoogleAuthClient { // Check if token is expired and we have a refresh token if (this.tokenStore.isExpired(tokens) && tokens.refresh_token) { - tokens = await this.refreshAccessToken(tokens.refresh_token); + try { + tokens = await this.refreshAccessToken(tokens.refresh_token); + } catch (error) { + // If refresh failed (e.g., invalid_grant), tokens are already cleared + // Re-throw with indication that re-authentication is needed + const message = error instanceof Error ? error.message : 'Token refresh failed'; + throw new Error(`invalid_grant: ${message}`); + } } if (!tokens.access_token) { @@ -305,17 +360,49 @@ export class GoogleAuthClient { */ async authenticate(openBrowser: (url: string) => void): Promise { try { + oauthLogger.writeToFile( + 'Starting OAuth authentication flow', + { type: 'auth_start' }, + findProjectRoot(), + ); + const authUrl = await this.generateAuthUrl(); openBrowser(authUrl); + oauthLogger.writeToFile( + 'Waiting for OAuth callback', + { type: 'waiting_callback' }, + findProjectRoot(), + ); + const { code } = await this.waitForCallback(); + + oauthLogger.writeToFile( + 'OAuth callback received, exchanging code', + { type: 'callback_received', has_code: !!code }, + findProjectRoot(), + ); + await this.exchangeCode(code); + oauthLogger.writeToFile( + 'OAuth authentication successful', + { type: 'auth_success' }, + findProjectRoot(), + ); + return { success: true }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + oauthLogger.writeToFile( + 'OAuth authentication failed', + { type: 'auth_failed', error: errorMessage }, + findProjectRoot(), + ); + return { success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: errorMessage, }; } } diff --git a/src/lib/services/firebase/oauth/config.ts b/src/lib/services/firebase/oauth/config.ts index a20093e..7618918 100644 --- a/src/lib/services/firebase/oauth/config.ts +++ b/src/lib/services/firebase/oauth/config.ts @@ -158,14 +158,8 @@ export const GOOGLE_OAUTH_CONFIG = { * OAuth scopes required for Firebase Management API. * * - firebase: List projects/apps, download configs, create apps - * - iam: Service Account management (create, list, generate keys) - * - cloud-platform: GCP project access (list projects, add Firebase to existing projects) */ - scopes: [ - 'https://www.googleapis.com/auth/firebase', - 'https://www.googleapis.com/auth/iam', - 'https://www.googleapis.com/auth/cloud-platform', - ], + scopes: ['https://www.googleapis.com/auth/firebase'], /** * Local redirect URI for OAuth callback. diff --git a/src/lib/services/organization-projects.ts b/src/lib/services/organization-projects.ts new file mode 100644 index 0000000..cac19a9 --- /dev/null +++ b/src/lib/services/organization-projects.ts @@ -0,0 +1,125 @@ +import { getInternalApiClient, type Organization, type Project } from '@/lib/api'; +import { getCredentialsManager } from '@/lib/auth'; +import { createLogger } from '@/lib/debug/logger'; + +const projectsLogger = createLogger('projects-fetch'); + +const DEFAULT_PROJECT_FETCH_CONCURRENCY = 4; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_RETRIES = 1; + +export interface OrgWithProjects { + org: Organization; + projects: Project[]; +} + +export interface FetchOrganizationsWithProjectsOptions { + projectFetchConcurrency?: number; + requestTimeoutMs?: number; + maxRetries?: number; +} + +function normalizeConcurrency(input?: number): number { + if (!input || !Number.isFinite(input) || input <= 0) { + return DEFAULT_PROJECT_FETCH_CONCURRENCY; + } + return Math.floor(input); +} + +async function mapWithConcurrency( + items: TInput[], + concurrency: number, + mapItem: (item: TInput, index: number) => Promise, +): Promise { + if (items.length === 0) return []; + + const normalizedConcurrency = Math.min(concurrency, items.length); + const results = new Array(items.length); + let nextIndex = 0; + + async function worker(): Promise { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + if (currentIndex >= items.length) return; + results[currentIndex] = await mapItem(items[currentIndex], currentIndex); + } + } + + await Promise.all(Array.from({ length: normalizedConcurrency }, () => worker())); + return results; +} + +export async function fetchOrganizationsWithProjects( + options: FetchOrganizationsWithProjectsOptions = {}, +): Promise { + const apiClient = getInternalApiClient(); + const credentialsManager = getCredentialsManager(); + const concurrency = normalizeConcurrency(options.projectFetchConcurrency); + const timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const startedAt = performance.now(); + + try { + const authToken = await credentialsManager.getValidToken(); + if (!authToken) { + return []; + } + + const orgFetchStartedAt = performance.now(); + const organizations = await apiClient.listOrganizations({ + authToken, + timeoutMs, + maxRetries, + }); + const orgFetchDurationMs = Math.round(performance.now() - orgFetchStartedAt); + + const projectsFetchStartedAt = performance.now(); + const orgsWithProjects = await mapWithConcurrency( + organizations, + concurrency, + async (org): Promise => { + try { + const projects = await apiClient.listProjects(org.id, { + authToken, + timeoutMs, + maxRetries, + }); + return { org, projects }; + } catch (error) { + if (projectsLogger.isEnabled()) { + projectsLogger.debug('Failed to fetch projects for org', { + orgId: org.id, + error: error instanceof Error ? error.message : String(error), + }); + } + return { org, projects: [] }; + } + }, + ); + const projectsFetchDurationMs = Math.round(performance.now() - projectsFetchStartedAt); + const totalDurationMs = Math.round(performance.now() - startedAt); + + if (projectsLogger.isEnabled()) { + projectsLogger.debug('Fetched organizations and projects', { + organizationCount: organizations.length, + projectCount: orgsWithProjects.reduce((sum, orgData) => sum + orgData.projects.length, 0), + concurrency, + timeoutMs, + maxRetries, + orgFetchDurationMs, + projectsFetchDurationMs, + totalDurationMs, + }); + } + + return orgsWithProjects; + } catch (error) { + if (projectsLogger.isEnabled()) { + projectsLogger.debug('Failed to fetch organizations and projects', { + error: error instanceof Error ? error.message : String(error), + }); + } + return []; + } +} diff --git a/src/lib/utils/oauth.ts b/src/lib/utils/oauth.ts index 91f7756..6d8bdb7 100644 --- a/src/lib/utils/oauth.ts +++ b/src/lib/utils/oauth.ts @@ -6,6 +6,8 @@ import { createHash, randomBytes } from 'node:crypto'; import { createServer, type Server } from 'node:http'; +import { oauthLogger } from '@/lib/debug/logger'; +import { findProjectRoot } from './path'; // ============================================================================ // Shared OAuth Callback Configuration @@ -101,14 +103,14 @@ const DEFAULT_SUCCESS_HTML = ` align-items: center; min-height: 100vh; margin: 0; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: #000000; } .container { text-align: center; padding: 40px; background: white; border-radius: 16px; - box-shadow: 0 10px 40px rgba(0,0,0,0.2); + box-shadow: 0 10px 40px rgba(255,255,255,0.1); } .icon { font-size: 64px; margin-bottom: 20px; } h1 { color: #333; margin: 0 0 10px; } @@ -138,16 +140,16 @@ function defaultErrorHtml(message: string): string { align-items: center; min-height: 100vh; margin: 0; - background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + background: #000000; } .container { text-align: center; padding: 40px; background: white; border-radius: 16px; - box-shadow: 0 10px 40px rgba(0,0,0,0.2); + box-shadow: 0 10px 40px rgba(255,255,255,0.1); } - .icon { font-size: 64px; margin-bottom: 20px; } + .icon { font-size: 64px; margin-bottom: 20px; color: #f5576c; } h1 { color: #333; margin: 0 0 10px; } p { color: #666; margin: 0; } @@ -258,12 +260,26 @@ export class OAuthCallbackServer { // Handle OAuth error if (error) { - const errorMsg = errorDescription || error; + const errorMsg = errorDescription ? `${error}: ${errorDescription}` : error; + + // Write debug info to .clix/debug.log + oauthLogger.writeToFile( + 'OAuth callback error', + { + type: 'oauth_callback_error', + error, + error_description: errorDescription, + full_url: req.url, + all_params: Object.fromEntries(url.searchParams.entries()), + }, + findProjectRoot(), + ); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.options.errorHtml(errorMsg)); clearTimeout(timeout); this.stop(); - reject(new Error(`OAuth error: ${errorMsg}`)); + reject(new Error(errorMsg)); return; } diff --git a/src/ui/LoginUI.tsx b/src/ui/LoginUI.tsx index f30b1d8..e9ea355 100644 --- a/src/ui/LoginUI.tsx +++ b/src/ui/LoginUI.tsx @@ -18,6 +18,10 @@ import { getProjectConfigManager, type ProjectConfig, } from '@/lib/config'; +import { + fetchOrganizationsWithProjects, + type OrgWithProjects, +} from '@/lib/services/organization-projects'; import { detectProjectType, formatProjectType } from '@/lib/services/project-detector'; import { Header } from '@/ui/components/Header'; import { ProjectSelector } from '@/ui/components/ProjectSelector'; @@ -33,11 +37,6 @@ type LoginPhase = | 'complete' | 'error'; -interface OrgWithProjects { - org: Organization; - projects: Project[]; -} - interface LoginUIProps { /** Called when login completes successfully */ onComplete?: (credentials: ClixCredentials) => void; @@ -71,22 +70,6 @@ async function fetchUserName( } } -/** Fetch organizations and their projects */ -async function fetchOrganizationsWithProjects(): Promise { - const orgsWithProjects: OrgWithProjects[] = []; - try { - const apiClient = getInternalApiClient(); - const orgs = await apiClient.listOrganizations(); - for (const org of orgs) { - const projects = await apiClient.listProjects(org.id); - orgsWithProjects.push({ org, projects }); - } - } catch { - // Silently ignore org/project fetch errors - } - return orgsWithProjects; -} - /** Check if user is already logged in with valid credentials */ async function checkExistingLogin(): Promise<{ isLoggedIn: boolean; userName: string }> { const credentialsManager = getCredentialsManager(); @@ -241,14 +224,23 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { const credentials = createClixCredentials(tokenResponse, issuer, config.audience); await credentialsManager.saveClixCredentials(credentials); - // Verify login + // Verify login and fetch data in parallel setPhase('verifying'); - const name = await fetchUserName(pkceService, tokenResponse); - setUserName(name); credentialsRef.current = credentials; - - // Fetch organizations and projects - const orgsData = await fetchOrganizationsWithProjects(); + const [memberResult, orgsData] = await Promise.all([ + fetchMember().catch(() => null), + fetchOrganizationsWithProjects(), + ]); + let name: string; + if (memberResult) { + name = memberResult.name || memberResult.email; + } else if (tokenResponse?.id_token) { + const userInfo = pkceService.parseIdToken(tokenResponse.id_token); + name = userInfo?.name ?? userInfo?.email ?? ''; + } else { + name = ''; + } + setUserName(name); setOrganizations(orgsData); // Check if there are projects to select from diff --git a/src/ui/SetupUI.tsx b/src/ui/SetupUI.tsx index 005b773..c3fb57a 100644 --- a/src/ui/SetupUI.tsx +++ b/src/ui/SetupUI.tsx @@ -17,6 +17,10 @@ import { getProjectConfigManager, type ProjectConfig, } from '@/lib/config'; +import { + fetchOrganizationsWithProjects, + type OrgWithProjects, +} from '@/lib/services/organization-projects'; import { detectProjectType } from '@/lib/services/project-detector'; import { Header } from '@/ui/components/Header'; import { ProjectSelector } from '@/ui/components/ProjectSelector'; @@ -33,11 +37,6 @@ type SetupPhase = | 'complete' | 'error'; -interface OrgWithProjects { - org: Organization; - projects: Project[]; -} - interface SetupUIProps { /** Called when setup completes successfully */ onComplete?: () => void; @@ -73,22 +72,6 @@ async function fetchUserName( } } -/** Fetch organizations and their projects */ -async function fetchOrganizationsWithProjects(): Promise { - const orgsWithProjects: OrgWithProjects[] = []; - try { - const apiClient = getInternalApiClient(); - const orgs = await apiClient.listOrganizations(); - for (const org of orgs) { - const projects = await apiClient.listProjects(org.id); - orgsWithProjects.push({ org, projects }); - } - } catch { - // Silently ignore org/project fetch errors - } - return orgsWithProjects; -} - export const SetupUI: React.FC = ({ onComplete, onError, projectPath }) => { const { exit } = useApp(); const [phase, setPhase] = useState('checking_auth'); @@ -185,15 +168,15 @@ export const SetupUI: React.FC = ({ onComplete, onError, projectPa const isAuthenticated = await credentialsManager.isAuthenticated(); if (isAuthenticated) { - // Already logged in, fetch data + // Already logged in, fetch data in parallel setPhase('fetching_data'); - const name = await fetchUserName(pkceService); + const [member, orgsData] = await Promise.all([ + fetchMember(), + fetchOrganizationsWithProjects(), + ]); + const name = member.name || member.email; setUserName(name); - - const member = await fetchMember(); memberRef.current = member; - - const orgsData = await fetchOrganizationsWithProjects(); setOrganizations(orgsData); // Check if there are projects to select from diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index 47900ba..ac37888 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -7,6 +7,7 @@ import type { InstallationMethod, UpdateCheckResult } from '../../lib/services/u import { AgentSelector } from '../components/AgentSelector'; import { DebugPrompt } from '../components/DebugPrompt'; import { FirebaseWizard } from '../components/FirebaseWizard'; +import { InstallPreparationUI } from '../components/InstallPreparationUI'; import { IosSetupFlow } from '../components/IosSetupFlow'; import { MCPInstallSelector } from '../components/MCPInstallSelector'; import { SessionSelector } from '../components/SessionSelector'; @@ -118,6 +119,14 @@ const ChatAppInner: React.FC [onExit, exit], ); + // Execute install skill with preparation context + const executeInstallWithContext = useCallback( + (context: Parameters[1]) => { + chatActions.executeSkill('install', context); + }, + [chatActions], + ); + // Overlay management const overlays = useOverlays({ currentAgent, @@ -127,6 +136,7 @@ const ChatAppInner: React.FC switchAgent: chatActions.switchAgent, resumeSession: chatActions.resumeSession, executeDebugSession: chatActions.executeDebugSession, + executeInstallWithContext, }); // Handle direct transfer command with agent name @@ -231,6 +241,7 @@ const ChatAppInner: React.FC {overlays.activeOverlay === 'firebase' && ( @@ -242,6 +253,13 @@ const ChatAppInner: React.FC }} /> )} + {overlays.activeOverlay === 'install-preparation' && ( + + )} {overlays.activeOverlay === 'login' && ( { diff --git a/src/ui/chat/hooks/useCommandHandler.ts b/src/ui/chat/hooks/useCommandHandler.ts index 9dc4c5a..47e22b2 100644 --- a/src/ui/chat/hooks/useCommandHandler.ts +++ b/src/ui/chat/hooks/useCommandHandler.ts @@ -47,6 +47,7 @@ interface UseCommandHandlerOptions { | 'showDebugPrompt' | 'showFirebaseWizard' | 'showIosSetupOverlay' + | 'showInstallPreparation' | 'showLoginOverlay' | 'showLogoutOverlay' | 'showWhoamiOverlay' @@ -91,6 +92,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showDebugPrompt, showFirebaseWizard, showIosSetupOverlay, + showInstallPreparation, showLoginOverlay, showLogoutOverlay, showWhoamiOverlay, @@ -192,8 +194,13 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { exit(); return; + case 'install': + // Install requires preparation step before agent execution + showInstallPreparation(); + return; + default: - // Handle skill commands + // Handle skill commands (except install which is handled above) if (getSkillCommands().has(command.name)) { executeSkill(command.name as SkillType); return; @@ -218,6 +225,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showDebugPrompt, showFirebaseWizard, showIosSetupOverlay, + showInstallPreparation, showLoginOverlay, showLogoutOverlay, showWhoamiOverlay, diff --git a/src/ui/chat/hooks/useMessageSending.ts b/src/ui/chat/hooks/useMessageSending.ts index 6e67118..181415d 100644 --- a/src/ui/chat/hooks/useMessageSending.ts +++ b/src/ui/chat/hooks/useMessageSending.ts @@ -2,6 +2,7 @@ * Message sending hook for user message submission. */ import { useCallback } from 'react'; +import type { PreparationContext } from '../../../commands/skill/preparation'; import { getDebugPrompt } from '../../../lib/services/debug-service'; import { executeSkill as executeSkillLib, getSkillInfo, type SkillType } from '../../../lib/skills'; import { generateMessageId, useChatContext } from '../context/ChatContext'; @@ -90,7 +91,7 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI ); const executeSkill = useCallback( - async (skillType: SkillType) => { + async (skillType: SkillType, preparationContext?: PreparationContext) => { if (!executorRef.current) { addSystemMessage('No agent configured. Please run "clix config" to select an agent.'); return; @@ -119,6 +120,7 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI projectPath: process.cwd(), signal, oneShot: false, // Chat mode: enable session persistence + preparationContext, }); await processStreamingMessages(messageGenerator, agentMessageId, { signal }); diff --git a/src/ui/chat/hooks/useOverlays.ts b/src/ui/chat/hooks/useOverlays.ts index a8af511..88fae27 100644 --- a/src/ui/chat/hooks/useOverlays.ts +++ b/src/ui/chat/hooks/useOverlays.ts @@ -7,6 +7,7 @@ import { listChatSessions, ONE_WEEK_MS, } from '@/lib/services/session-store'; +import type { PreparationContext } from '../../../commands/skill/preparation'; import type { AgentInfo } from '../../../lib/agents'; import { detectAvailableAgents } from '../../../lib/agents'; import { @@ -25,6 +26,7 @@ export type OverlayType = | 'debug' | 'firebase' | 'ios-setup' + | 'install-preparation' | 'login' | 'logout' | 'whoami' @@ -38,6 +40,7 @@ interface UseOverlaysOptions { switchAgent: ReturnType['switchAgent']; resumeSession: ReturnType['resumeSession']; executeDebugSession: ReturnType['executeDebugSession']; + executeInstallWithContext?: (context: PreparationContext) => void; } /** @@ -52,6 +55,7 @@ export function useOverlays(options: UseOverlaysOptions) { switchAgent, resumeSession, executeDebugSession, + executeInstallWithContext, } = options; // Active overlay @@ -117,6 +121,7 @@ export function useOverlays(options: UseOverlaysOptions) { const showDebugPrompt = useCallback(() => setActiveOverlay('debug'), []); const showFirebaseWizard = useCallback(() => setActiveOverlay('firebase'), []); const showIosSetupOverlay = useCallback(() => setActiveOverlay('ios-setup'), []); + const showInstallPreparation = useCallback(() => setActiveOverlay('install-preparation'), []); const showLoginOverlay = useCallback(() => setActiveOverlay('login'), []); const showLogoutOverlay = useCallback(() => setActiveOverlay('logout'), []); const showWhoamiOverlay = useCallback(() => setActiveOverlay('whoami'), []); @@ -264,6 +269,20 @@ export function useOverlays(options: UseOverlaysOptions) { [addSystemMessage], ); + // Install preparation handlers + const handleInstallPreparationComplete = useCallback( + (context: PreparationContext) => { + setActiveOverlay(null); + executeInstallWithContext?.(context); + }, + [executeInstallWithContext], + ); + + const handleInstallPreparationCancel = useCallback(() => { + setActiveOverlay(null); + addSystemMessage('Install preparation cancelled'); + }, [addSystemMessage]); + // Login handlers const handleLoginComplete = useCallback( (message: string) => { @@ -335,6 +354,11 @@ export function useOverlays(options: UseOverlaysOptions) { showIosSetupOverlay, handleIosSetupComplete, + // Install preparation + showInstallPreparation, + handleInstallPreparationComplete, + handleInstallPreparationCancel, + // Auth overlays showLoginOverlay, handleLoginComplete, diff --git a/src/ui/components/FirebaseWizard.tsx b/src/ui/components/FirebaseWizard.tsx index 50f5c85..b8657fb 100644 --- a/src/ui/components/FirebaseWizard.tsx +++ b/src/ui/components/FirebaseWizard.tsx @@ -21,21 +21,20 @@ import { parseServiceAccountJson, platformNeedsAndroid, platformNeedsIos, - quickValidateServiceAccountJson, - type ServiceAccount, type ServiceAccountJson, type ServiceAccountValidationResult, - type WizardPhase, } from '@/lib/services/firebase'; import { detectProjectType } from '@/lib/services/project-detector'; import { OAUTH_CALLBACK_CONFIG } from '@/lib/utils/oauth'; import { useCancelInput } from '@/ui/hooks'; import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; +import { type ExtendedWizardPhase, transition } from './firebase-wizard-transitions'; import { GenericSelector, type SelectorItem } from './GenericSelector'; interface FirebaseWizardProps { projectPath: string; projectType?: ProjectType; + clixProjectId?: string; onComplete: (result: FirebaseSetupResult) => void; onCancel?: () => void; } @@ -44,31 +43,6 @@ interface MenuAction extends SelectorItem { action: CredentialAction; } -/** - * Extended wizard phase for download flow. - */ -type ExtendedWizardPhase = - | WizardPhase - | 'authenticating' - | 'select_project' - | 'select_android_app' - | 'select_ios_app' - | 'downloading' - | 'no_apps_found' - | 'create_android_app' - | 'create_ios_app' - | 'creating_app' - // New phases for no projects and service account - | 'no_projects' - | 'select_gcp_project' - | 'adding_firebase' - | 'service_account_menu' - | 'select_service_account' - | 'create_service_account' - | 'creating_service_account' - | 'paste_service_account' - | 'saving_service_account'; - /** * No apps found context (which platform apps are missing). */ @@ -205,8 +179,9 @@ function buildMenuItems(result: FirebaseDetectionResult): MenuAction[] { items.push(...buildAndroidMenuItems(result, needsAndroid)); items.push(...buildIosMenuItems(result, needsIos)); - // Service Account setup (if OAuth is configured and project is configured) - if (isOAuthConfigured() && result.android?.valid) { + // Service Account setup (if OAuth is configured and any platform config is valid) + const hasValidConfig = result.android?.valid || result.ios?.valid; + if (isOAuthConfigured() && hasValidConfig) { items.push({ id: 'setup-service-account', label: '🔑 Setup Service Account', @@ -748,8 +723,7 @@ function AddingFirebasePhase({ projectId }: { projectId: string }): React.ReactE * Service Account menu action type. */ type ServiceAccountMenuAction = - | { type: 'select_existing' } - | { type: 'create_new' } + | { type: 'open_console' } | { type: 'paste_json' } | { type: 'skip' }; @@ -767,12 +741,8 @@ function ServiceAccountMenuPhase({ }): React.ReactElement { const items: Array<{ label: string; value: ServiceAccountMenuAction }> = [ { - label: '➕ Create new Service Account', - value: { type: 'create_new' }, - }, - { - label: '📋 Select existing Service Account', - value: { type: 'select_existing' }, + label: '🌐 Download from Firebase Console', + value: { type: 'open_console' }, }, { label: '📄 Paste Service Account JSON', @@ -796,7 +766,7 @@ function ServiceAccountMenuPhase({ marginY={1} > - 🔑 Service Account Setup + Service Account Setup Project: {projectId} @@ -804,6 +774,12 @@ function ServiceAccountMenuPhase({ Service Account is required for server-side Firebase Admin SDK. + + + Download the private key JSON from Firebase Console (Project Settings → Service Accounts → + Generate new private key), then paste it here. + + onAction(item.value)} /> ↑↓ navigate · Enter select · Esc/Ctrl+C cancel @@ -813,24 +789,123 @@ function ServiceAccountMenuPhase({ } /** - * Service Account selector component. + * Import Service Account JSON phase component. + * Accepts a file path to a downloaded JSON file. */ -function ServiceAccountSelector({ - accounts, - onSelect, +/** + * Read text content from system clipboard. + * Uses pbpaste on macOS, xclip or xsel on Linux. + */ +async function readClipboard(): Promise { + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execFileAsync = promisify(execFile); + + const platform = process.platform; + try { + if (platform === 'darwin') { + const { stdout } = await execFileAsync('pbpaste', []); + return stdout; + } + if (platform === 'linux') { + try { + const { stdout } = await execFileAsync('xclip', ['-selection', 'clipboard', '-o']); + return stdout; + } catch { + const { stdout } = await execFileAsync('xsel', ['--clipboard', '--output']); + return stdout; + } + } + return null; + } catch { + return null; + } +} + +/** + * Read file content from a file path. + * Supports ~ home directory and relative paths. + */ +async function readFileFromPath(filePath: string): Promise { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + // Strip surrounding quotes (from drag & drop on some terminals) + const cleaned = filePath.replace(/^['"]|['"]$/g, ''); + const resolved = cleaned.startsWith('~') + ? path.join(process.env.HOME || '', cleaned.slice(1)) + : path.resolve(cleaned); + + return fs.readFile(resolved, 'utf-8'); +} + +function PasteServiceAccountPhase({ + onSubmit, onCancel, }: { - accounts: ServiceAccount[]; - onSelect: (account: ServiceAccount) => void; + onSubmit: (json: ServiceAccountJson) => void; onCancel: () => void; }): React.ReactElement { - const items = accounts.map((a) => ({ - label: a.displayName || a.email, - value: a, - })); + const [input, setInput] = useState(''); + const [validation, setValidation] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingText, setLoadingText] = useState(''); useCancelInput(onCancel); + const processJson = useCallback( + (content: string, source: string) => { + const result = parseServiceAccountJson(content); + if (result.valid && result.data) { + setValidation(result); + onSubmit(result.data); + } else { + setValidation(result); + setErrorMsg(`Invalid Service Account JSON (from ${source})`); + } + }, + [onSubmit], + ); + + const handleSubmit = useCallback(async () => { + const trimmed = input.trim(); + + setLoading(true); + setErrorMsg(null); + setValidation(null); + + try { + if (!trimmed) { + // Empty input → read from clipboard + setLoadingText('Reading from clipboard...'); + const clipboard = await readClipboard(); + if (!clipboard?.trim()) { + setErrorMsg('Clipboard is empty. Copy the JSON content first.'); + return; + } + processJson(clipboard, 'clipboard'); + } else if (trimmed.startsWith('{')) { + // JSON-like input → parse directly + processJson(trimmed, 'input'); + } else { + // File path → read file + setLoadingText('Reading file...'); + const content = await readFileFromPath(trimmed); + processJson(content, 'file'); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + setErrorMsg('File not found. Please check the path.'); + } else { + setErrorMsg(err instanceof Error ? err.message : 'Failed to read input'); + } + } finally { + setLoading(false); + setLoadingText(''); + } + }, [input, processJson]); + return ( - Select Service Account + Import Service Account JSON - onSelect(item.value)} /> + + + Firebase Console → Project Settings → Service accounts → Generate new private key + + + + 1. Copy JSON to clipboard → press Enter + 2. Drag the JSON file here → press Enter + + + + {'> '} + + + + {loading && ( + + + + + {loadingText} + + )} + + {errorMsg && ( + + ✗ {errorMsg} + + )} + + {validation && !validation.valid && ( + + Validation errors: + {validation.errors.map((err) => ( + + • {err} + + ))} + + )} + - ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + Enter to import · Esc/Ctrl+C cancel ); } /** - * Create Service Account input phase component. + * Saving Service Account phase component. */ -function CreateServiceAccountInputPhase({ - projectId, - onSubmit, - onCancel, -}: { - projectId: string; - onSubmit: (accountId: string, displayName?: string) => void; - onCancel: () => void; -}): React.ReactElement { - const [accountId, setAccountId] = useState('clix-firebase-admin'); - const [displayName, setDisplayName] = useState(''); - const [stage, setStage] = useState<'accountId' | 'displayName'>('accountId'); - - useCancelInput(onCancel); - - const handleAccountIdSubmit = useCallback(() => { - if (accountId.trim()) { - setStage('displayName'); - } - }, [accountId]); - - const handleDisplayNameSubmit = useCallback(() => { - onSubmit(accountId.trim(), displayName.trim() || undefined); - }, [accountId, displayName, onSubmit]); - +function SavingServiceAccountPhase(): React.ReactElement { return ( - Create Service Account + Saving Service Account - - Project: {projectId} + + + + + Saving service account key... - - {stage === 'accountId' ? ( - <> - - - Account ID: (e.g., clix-firebase-admin) - - - - {'> '} - - - - Enter to continue · Esc/Ctrl+C cancel - - - ) : ( - <> - - Account ID: {accountId} - - - - Display Name: (optional) - - - - {'> '} - - - - Enter to create · Esc/Ctrl+C cancel - - - )} ); } /** - * Creating Service Account phase component. + * Checking sender config phase component. */ -function CreatingServiceAccountPhase(): React.ReactElement { +function CheckingSenderConfigPhase(): React.ReactElement { return ( - Service Account Creation + Firebase Configuration - Creating service account and generating key... - - - This may take a moment... + Checking push notification configuration... ); } /** - * Paste Service Account JSON phase component with real-time validation. + * Sender config already registered phase component. */ -function PasteServiceAccountPhase({ - onSubmit, +function SenderConfigRegisteredPhase({ + updatedAt, + onContinue, onCancel, }: { - onSubmit: (json: ServiceAccountJson) => void; + updatedAt: string | null; + onContinue: () => void; onCancel: () => void; }): React.ReactElement { - const [jsonInput, setJsonInput] = useState(''); - const [validation, setValidation] = useState(null); - const [quickValidation, setQuickValidation] = useState | null>(null); - - useCancelInput(onCancel); - - // Real-time validation - useEffect(() => { - if (!jsonInput.trim()) { - setValidation(null); - setQuickValidation(null); - return; + useInput((_input, key) => { + if (key.return) { + onContinue(); } + }); - // Quick validation for UI feedback - const quick = quickValidateServiceAccountJson(jsonInput); - setQuickValidation(quick); - - // Full validation if it looks like service account JSON - if (quick.isJson && quick.isServiceAccount) { - const result = parseServiceAccountJson(jsonInput); - setValidation(result); - } else { - setValidation(null); - } - }, [jsonInput]); + useCancelInput(onCancel); - const handleSubmit = useCallback(() => { - if (validation?.valid && validation.data) { - onSubmit(validation.data); - } - }, [validation, onSubmit]); + const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : 'unknown'; return ( - Paste Service Account JSON + Push Notification Configuration - - - Firebase Console → Project Settings → Service accounts → Generate new private key - + + ✓ FCM sender config is already registered - - - {'> '} - + + Configured at: {formattedDate} - - {/* Validation status display */} - {jsonInput.trim() && ( - - {quickValidation && ( - <> - - - {quickValidation.isJson ? '✓' : '✗'} JSON format - - - {quickValidation.isJson && ( - <> - - - {quickValidation.isServiceAccount ? '✓' : '✗'} Service Account type - - - - - {quickValidation.hasPrivateKey ? '✓' : '✗'} Private key present - - - {quickValidation.projectId && ( - - Project: {quickValidation.projectId} - - )} - - )} - - )} - - {/* Full validation errors */} - {validation && !validation.valid && ( - - Validation errors: - {validation.errors.map((err) => ( - - • {err} - - ))} - - )} - - {/* Success message */} - {validation?.valid && ( - - - ✓ Valid Service Account JSON - Press Enter to save - - - )} - - )} - - {validation?.valid ? 'Enter to save · ' : ''}Esc/Ctrl+C cancel + Press Enter to continue, Esc/Ctrl+C to cancel ); } /** - * Saving Service Account phase component. + * Registering sender config phase component. */ -function SavingServiceAccountPhase(): React.ReactElement { +function RegisteringSenderConfigPhase({ + result, +}: { + result: 'success' | 'failed' | null; +}): React.ReactElement { return ( - Saving Service Account - - - - - - Saving service account key... + Push Notification Configuration + {result === 'success' ? ( + + ✓ Push notification configured successfully + + ) : result === 'failed' ? ( + + + ✗ Failed to register sender config (can be configured later in Console) + + + ) : ( + + + + + Registering push notification configuration... + + )} ); } @@ -1287,6 +1266,20 @@ Add this to your OAuth client: ${OAUTH_CALLBACK_CONFIG.getCallbackUrlIp()}`; if (error.includes('invalid_grant')) { return `The authorization code has expired or already been used. Please try again.`; + } + if (error.includes('invalid_request')) { + return `The OAuth request was malformed or missing required parameters. +This can happen when: +- OAuth client redirect URI is misconfigured +- Required PKCE parameters are missing or invalid +- Browser session expired before completing auth + +Check .clix/debug.log for detailed error information.`; + } + if (error.includes('API has not been used in project') || error.includes('it is disabled')) { + return `A required Google Cloud API is not enabled for this project. +Visit the URL shown above to enable it in Google Cloud Console. +After enabling, wait a few minutes then press Enter to retry.`; } return null; } @@ -1335,6 +1328,11 @@ function ErrorPhase({ {hint} )} + {!hint && ( + + See .clix/debug.log for details + + )} Press Enter to retry, Esc/Ctrl+C to skip @@ -1401,6 +1399,7 @@ function CompletePhase({ export const FirebaseWizard: React.FC = ({ projectPath, projectType: propProjectType, + clixProjectId, onComplete, onCancel, }) => { @@ -1430,9 +1429,12 @@ export const FirebaseWizard: React.FC = ({ const [selectedGcpProject, setSelectedGcpProject] = useState(null); // Service Account flow state - const [serviceAccounts, setServiceAccounts] = useState([]); const [, setServiceAccountJson] = useState(null); + // Sender config state + const [senderConfigUpdatedAt, setSenderConfigUpdatedAt] = useState(null); + const [senderConfigResult, setSenderConfigResult] = useState<'success' | 'failed' | null>(null); + const [service, setService] = useState(null); const [projectType, setProjectType] = useState(propProjectType ?? null); @@ -1446,10 +1448,10 @@ export const FirebaseWizard: React.FC = ({ setService(firebaseService); const detectionResult = await firebaseService.detect(); setResult(detectionResult); - setPhase('status'); + setPhase(transition('detecting', 'success')); } catch (err) { setError(err instanceof Error ? err.message : 'Detection failed'); - setPhase('error'); + setPhase(transition('detecting', 'error')); } }; @@ -1458,13 +1460,47 @@ export const FirebaseWizard: React.FC = ({ } }, [phase, projectPath, propProjectType]); + // Sender config check effect + useEffect(() => { + if (phase !== 'checking_sender_config') return; + + const checkSenderConfig = async () => { + // Skip API check if no Clix project linked, but still go to SA setup + if (!clixProjectId) { + setPhase(transition('checking_sender_config', 'skip')); + return; + } + + try { + const { getInternalApiClient } = await import('@/lib/api'); + const apiClient = getInternalApiClient(); + const project = await apiClient.getProject(clixProjectId); + const pushConfig = project.sender_configs?.find( + (c) => c.channel_type === 'CHANNEL_TYPE_APP_PUSH', + ); + + if (pushConfig) { + setSenderConfigUpdatedAt(pushConfig.updated_at ?? pushConfig.created_at ?? null); + setPhase(transition('checking_sender_config', 'registered')); + } else { + setPhase(transition('checking_sender_config', 'not_registered')); + } + } catch { + // API error should not block setup, still go to SA menu + setPhase(transition('checking_sender_config', 'error')); + } + }; + + checkSenderConfig(); + }, [phase, clixProjectId]); + const handleContinue = useCallback(() => { - setPhase('menu'); + setPhase(transition('status', 'continue')); }, []); const handleSkip = useCallback(() => { setSkipped(true); - setPhase('complete'); + setPhase(transition('menu', 'skip')); onComplete({ completed: false, skipped: true, @@ -1487,7 +1523,7 @@ export const FirebaseWizard: React.FC = ({ async (project: FirebaseProject, androidApp: AndroidApp | null, iosApp: IosApp | null) => { if (!projectType) { setError('Project type not detected'); - setPhase('error'); + setPhase(transition('select_project', 'error')); return; } @@ -1498,7 +1534,7 @@ export const FirebaseWizard: React.FC = ({ } else { setDownloadingPlatform('ios'); } - setPhase('downloading'); + setPhase('downloading'); // Direct set: entry point from multiple phases try { const paths = downloader.getExpectedSavePaths(projectPath, projectType); @@ -1520,10 +1556,10 @@ export const FirebaseWizard: React.FC = ({ const newResult = await service.detect(); setResult(newResult); } - setPhase('status'); + setPhase(transition('downloading', 'success')); } catch (err) { setError(err instanceof Error ? err.message : 'Download failed'); - setPhase('error'); + setPhase(transition('downloading', 'error')); } }, [projectPath, projectType, downloader, service], @@ -1566,7 +1602,7 @@ export const FirebaseWizard: React.FC = ({ } else if (apps.length === 1) { downloadConfigsRef.current(project, app, apps[0]); } else { - setPhase('select_ios_app'); + setPhase(transition('select_android_app', 'select_ios_app')); } } catch { // Failed to get iOS apps, just download Android @@ -1593,7 +1629,7 @@ export const FirebaseWizard: React.FC = ({ if (apps.length === 1) { androidAppSelectRef.current(apps[0], project, needsIosConfig); } else { - setPhase('select_android_app'); + setPhase(transition('select_project', 'select_android_app')); } return true; }, @@ -1613,7 +1649,7 @@ export const FirebaseWizard: React.FC = ({ if (apps.length === 1) { iosAppSelectRef.current(apps[0], project); } else { - setPhase('select_ios_app'); + setPhase(transition('select_project', 'select_ios_app')); } return true; }, @@ -1662,7 +1698,7 @@ export const FirebaseWizard: React.FC = ({ if (!shouldFetchAndroid && !shouldFetchIos) { setError('No configuration files needed for this platform.'); - setPhase('error'); + setPhase(transition('select_project', 'error')); return; } @@ -1680,11 +1716,11 @@ export const FirebaseWizard: React.FC = ({ needsAndroid: shouldFetchAndroid, needsIos: shouldFetchIos, }); - setPhase('no_apps_found'); + setPhase(transition('select_project', 'no_apps_found')); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch apps'); - setPhase('error'); + setPhase(transition('select_project', 'error')); } }, [getPlatformNeeds, fetchAppsForPlatforms, result], @@ -1696,7 +1732,7 @@ export const FirebaseWizard: React.FC = ({ // Handle download authentication const handleDownload = useCallback(async () => { - setPhase('authenticating'); + setPhase('authenticating'); // Direct set: entry point from menu/download action try { // Check if already authenticated @@ -1707,7 +1743,7 @@ export const FirebaseWizard: React.FC = ({ const authResult = await downloader.authenticate(openBrowser); if (!authResult.success) { setError(authResult.error || 'Authentication failed. Please try again.'); - setPhase('error'); + setPhase(transition('authenticating', 'error')); return; } } @@ -1716,7 +1752,7 @@ export const FirebaseWizard: React.FC = ({ const fetchedProjects = await downloader.listProjects(); if (fetchedProjects.length === 0) { // No Firebase projects - show options to create or add Firebase to GCP project - setPhase('no_projects'); + setPhase(transition('authenticating', 'no_projects')); return; } @@ -1726,16 +1762,16 @@ export const FirebaseWizard: React.FC = ({ if (fetchedProjects.length === 1) { projectSelectRef.current(fetchedProjects[0]); } else { - setPhase('select_project'); + setPhase(transition('authenticating', 'select_project')); } } catch (err) { setError(err instanceof Error ? err.message : 'Authentication failed'); - setPhase('error'); + setPhase(transition('authenticating', 'error')); } }, [downloader]); // Handler for Service Account setup (defined before handleAction to be used in it) - const handleServiceAccountSetup = useCallback(async () => { + const handleServiceAccountSetup = useCallback(() => { // Get project ID from detection result const projectId = getProjectIdFromResult(result); if (!projectId) { @@ -1744,26 +1780,8 @@ export const FirebaseWizard: React.FC = ({ return; } - setPhase('authenticating'); - - try { - // Ensure authenticated - const isAuth = await downloader.isAuthenticated(); - if (!isAuth) { - const authResult = await downloader.authenticate(openBrowser); - if (!authResult.success) { - setError(authResult.error || 'Authentication failed'); - setPhase('error'); - return; - } - } - - setPhase('service_account_menu'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Authentication failed'); - setPhase('error'); - } - }, [downloader, result]); + setPhase(transition('menu', 'setup_service_account')); + }, [result]); const handleAction = useCallback( async (action: CredentialAction) => { @@ -1773,27 +1791,27 @@ export const FirebaseWizard: React.FC = ({ break; case 'redetect': - setPhase('detecting'); + setPhase(transition('menu', 'redetect')); break; case 'redetect_platform': setValidatingPlatform(action.platform); - setPhase('detecting'); + setPhase(transition('menu', 'redetect_platform')); break; case 'validate': setValidatingPlatform(action.platform); - setPhase('validating'); + setPhase(transition('menu', 'validate')); // Re-detect to validate try { if (service) { const newResult = await service.detect(); setResult(newResult); } - setPhase('status'); + setPhase(transition('validating', 'success')); } catch (err) { setError(err instanceof Error ? err.message : 'Validation failed'); - setPhase('error'); + setPhase(transition('validating', 'error')); } break; @@ -1808,7 +1826,7 @@ export const FirebaseWizard: React.FC = ({ break; case 'done': - setPhase('complete'); + setPhase(transition('menu', 'done')); onComplete({ completed: true, skipped: false, @@ -1826,8 +1844,8 @@ export const FirebaseWizard: React.FC = ({ ); const handleRetry = useCallback(async () => { - // Check if this was a scope error - if so, logout and re-authenticate - if (error && isScopeInsufficientError(error)) { + // Check if this was a scope error or invalid_grant - if so, logout and re-authenticate + if (error && (isScopeInsufficientError(error) || error.includes('invalid_grant'))) { // Clear tokens and trigger re-authentication await downloader.logout(); setError(null); @@ -1837,7 +1855,7 @@ export const FirebaseWizard: React.FC = ({ } setError(null); - setPhase('detecting'); + setPhase(transition('error', 'retry')); }, [error, downloader, handleDownload]); const handleCancel = useCallback(() => { @@ -1858,7 +1876,7 @@ export const FirebaseWizard: React.FC = ({ } setCreatingAppPlatform(platform); - setPhase('creating_app'); + setPhase('creating_app'); // Direct set: entry point from create_android_app/create_ios_app try { if (platform === 'android') { @@ -1877,7 +1895,7 @@ export const FirebaseWizard: React.FC = ({ ...noAppsContext, noAndroidApps: false, }); - setPhase('no_apps_found'); + setPhase(transition('creating_app', 'no_apps_found')); } else if (needsIos) { // Fetch iOS apps const iosAppsList = await downloader.listIosApps(selectedProject.projectId); @@ -1888,7 +1906,7 @@ export const FirebaseWizard: React.FC = ({ downloadConfigsRef.current(selectedProject, app, iosAppsList[0]); } else { setIosApps(iosAppsList); - setPhase('select_ios_app'); + setPhase(transition('creating_app', 'select_ios_app')); } } else { // Only needed Android, download config @@ -1906,7 +1924,7 @@ export const FirebaseWizard: React.FC = ({ } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create app'); - setPhase('error'); + setPhase(transition('creating_app', 'error')); } }, [selectedProject, downloader, getPlatformNeeds, noAppsContext, selectedAndroidApp], @@ -1915,12 +1933,12 @@ export const FirebaseWizard: React.FC = ({ // Handlers for no apps found phase const handleStartCreateAndroid = useCallback(() => { setCreatingAppPlatform('android'); - setPhase('create_android_app'); + setPhase(transition('no_apps_found', 'create_android')); }, []); const handleStartCreateIos = useCallback(() => { setCreatingAppPlatform('ios'); - setPhase('create_ios_app'); + setPhase(transition('no_apps_found', 'create_ios')); }, []); const handleCreateAndroidSubmit = useCallback( @@ -1939,31 +1957,31 @@ export const FirebaseWizard: React.FC = ({ const handleNoAppsCancel = useCallback(() => { // Go back to project selection - setPhase('select_project'); + setPhase(transition('no_apps_found', 'cancel')); }, []); // Handler for opening Firebase Console const handleOpenFirebaseConsole = useCallback(() => { openBrowser('https://console.firebase.google.com/'); // Go back to menu after opening - setPhase('menu'); + setPhase(transition('no_projects', 'open_console')); }, []); // Handler for fetching GCP projects (to add Firebase) const handleFetchGcpProjects = useCallback(async () => { - setPhase('authenticating'); + setPhase('authenticating'); // Direct set: entry point from no_projects try { const availableGcpProjects = await downloader.listAvailableGcpProjects(); if (availableGcpProjects.length === 0) { setError('No GCP projects available to add Firebase. Create a project first.'); - setPhase('error'); + setPhase(transition('authenticating', 'error')); return; } setGcpProjects(availableGcpProjects); - setPhase('select_gcp_project'); + setPhase(transition('authenticating', 'select_gcp_project')); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch GCP projects'); - setPhase('error'); + setPhase(transition('authenticating', 'error')); } }, [downloader]); @@ -1971,7 +1989,7 @@ export const FirebaseWizard: React.FC = ({ const handleGcpProjectSelect = useCallback( async (project: GcpProject) => { setSelectedGcpProject(project); - setPhase('adding_firebase'); + setPhase(transition('select_gcp_project', 'adding_firebase')); try { const firebaseProject = await downloader.addFirebaseToProject(project.projectId); @@ -1980,7 +1998,7 @@ export const FirebaseWizard: React.FC = ({ projectSelectRef.current(firebaseProject); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to add Firebase to project'); - setPhase('error'); + setPhase(transition('adding_firebase', 'error')); } }, [downloader], @@ -1988,124 +2006,105 @@ export const FirebaseWizard: React.FC = ({ // Handler for Service Account menu action const handleServiceAccountMenuAction = useCallback( - async (action: ServiceAccountMenuAction) => { + (action: ServiceAccountMenuAction) => { const projectId = getProjectIdFromResult(result); if (!projectId) { setError('No project ID found'); - setPhase('error'); + setPhase(transition('service_account_menu', 'error')); return; } switch (action.type) { - case 'select_existing': - setPhase('authenticating'); - try { - const accounts = await downloader.listServiceAccounts(projectId); - if (accounts.length === 0) { - setError('No service accounts found. Create a new one instead.'); - setPhase('service_account_menu'); - return; - } - setServiceAccounts(accounts); - setPhase('select_service_account'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch service accounts'); - setPhase('error'); - } - break; - - case 'create_new': - setPhase('create_service_account'); + case 'open_console': + openBrowser( + `https://console.firebase.google.com/project/${projectId}/settings/serviceaccounts/adminsdk`, + ); + // Stay on menu so user can paste after downloading + setPhase(transition('service_account_menu', 'open_console')); break; case 'paste_json': - setPhase('paste_service_account'); + setPhase(transition('service_account_menu', 'paste_json')); break; case 'skip': - setPhase('status'); + setPhase(transition('service_account_menu', 'skip')); break; } }, - [downloader, result], + [result], ); - // Handler for selecting existing service account - const handleSelectServiceAccount = useCallback( - async (account: ServiceAccount) => { - const projectId = getProjectIdFromResult(result); - if (!projectId) { - setError('No project ID found'); - setPhase('error'); - return; - } - - setPhase('creating_service_account'); + // Handler for pasting service account JSON + // Only saves the file, then transitions to registering_sender_config or complete + const handleSaveServiceAccountJson = useCallback( + async (json: ServiceAccountJson) => { + setPhase(transition('paste_service_account', 'save')); try { - const json = await downloader.downloadServiceAccountKey(projectId, account.email); setServiceAccountJson(json); await downloader.saveServiceAccountJson(projectPath, json); - setPhase('status'); + + // Decide next phase: register sender config if Clix project is linked + if (clixProjectId) { + setPhase(transition('saving_service_account', 'register')); + } else { + setPhase(transition('saving_service_account', 'complete')); + onComplete({ completed: true, skipped: false, detection: result }); + } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to generate service account key'); - setPhase('error'); + setError(err instanceof Error ? err.message : 'Failed to save service account'); + setPhase(transition('saving_service_account', 'error')); } }, - [downloader, result, projectPath], + [downloader, projectPath, clixProjectId, onComplete, result], ); - // Handler for creating new service account - const handleCreateServiceAccount = useCallback( - async (accountId: string, displayName?: string) => { - const projectId = getProjectIdFromResult(result); - if (!projectId) { - setError('No project ID found'); - setPhase('error'); - return; - } - - setPhase('creating_service_account'); + // Sender config registration effect - triggered when entering registering_sender_config phase + useEffect(() => { + if (phase !== 'registering_sender_config' || !clixProjectId) return; + const registerSenderConfig = async () => { try { - const json = await downloader.createServiceAccountWithKey( - projectId, - accountId, - displayName, - ); - setServiceAccountJson(json); - await downloader.saveServiceAccountJson(projectPath, json); - setPhase('status'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create service account'); - setPhase('error'); + const { getInternalApiClient } = await import('@/lib/api'); + const apiClient = getInternalApiClient(); + const saJsonPath = `${projectPath}/.clix/firebase-service-account.json`; + const { readFile } = await import('node:fs/promises'); + const saContent = await readFile(saJsonPath, 'utf-8'); + const encoded = btoa(saContent); + await apiClient.createOrUpdateSenderConfig(clixProjectId, { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { fcm_sa_json_base64_encoded: encoded }, + android_config: { fcm_sa_json_base64_encoded: encoded }, + }, + }); + setSenderConfigResult('success'); + } catch { + setSenderConfigResult('failed'); } - }, - [downloader, result, projectPath], - ); - // Handler for pasting service account JSON - const handleSaveServiceAccountJson = useCallback( - async (json: ServiceAccountJson) => { - setPhase('saving_service_account'); + // Auto-complete after brief display + setTimeout(() => { + setPhase(transition('registering_sender_config', 'complete')); + onComplete({ completed: true, skipped: false, detection: result }); + }, 1500); + }; - try { - setServiceAccountJson(json); - await downloader.saveServiceAccountJson(projectPath, json); - setPhase('status'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save service account'); - setPhase('error'); - } - }, - [downloader, projectPath], - ); + registerSenderConfig(); + }, [phase, clixProjectId, projectPath, onComplete, result]); // Handler for going back to service account menu const handleServiceAccountCancel = useCallback(() => { - setPhase('service_account_menu'); + setPhase(transition('paste_service_account', 'cancel')); }, []); + // Handler for sender config registered → complete wizard + const handleSenderConfigContinue = useCallback(() => { + setPhase(transition('sender_config_registered', 'continue')); + onComplete({ completed: true, skipped: false, detection: result }); + }, [onComplete, result]); + switch (phase) { case 'detecting': return ; @@ -2239,37 +2238,31 @@ export const FirebaseWizard: React.FC = ({ /> ); - case 'select_service_account': + case 'paste_service_account': return ( - ); - case 'create_service_account': - return ( - - ); + case 'saving_service_account': + return ; - case 'creating_service_account': - return ; + case 'checking_sender_config': + return ; - case 'paste_service_account': + case 'sender_config_registered': return ( - ); - case 'saving_service_account': - return ; + case 'registering_sender_config': + return ; case 'error': return ( diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index a5a17f1..bdf53bc 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -46,6 +46,26 @@ interface ActionItem extends SelectorItem { action: 'configure' | 'skip' | 'cancel'; } +export function getInitialPreparationPhase(context: PreparationContext): PreparationPhase { + if (!context.firebase.configured && context.firebase.needed) { + return 'firebase_check'; + } + if (!context.ios.entitlementsConfigured && context.ios.needed) { + return 'ios_check'; + } + if (context.ready) { + return 'ready'; + } + return 'firebase_check'; +} + +export function getPostFirebasePhase(context: PreparationContext): PreparationPhase { + if (context.ios.needed && !context.ios.entitlementsConfigured) { + return 'ios_setup'; + } + return 'ready'; +} + function StatusLine({ label, status, @@ -230,29 +250,63 @@ function ReadyPhase({ onContinue: () => void; onCancel: () => void; }): React.ReactElement { - useCancelInput(onCancel); + const items: ActionItem[] = [ + { id: 'continue', label: 'Continue to installation', action: 'configure' }, + { id: 'cancel', label: 'Cancel', action: 'cancel' }, + ]; - useEffect(() => { - // Auto-continue after a brief moment - const timer = setTimeout(onContinue, 500); - return () => clearTimeout(timer); - }, [onContinue]); + const handleSelect = useCallback( + (item: ActionItem) => { + if (item.action === 'cancel') { + onCancel(); + } else { + onContinue(); + } + }, + [onCancel, onContinue], + ); return ( - ✓ Preparation complete + ✓ Preparation review complete Project: {context.config.project.name} Type: {formatProjectType(context.projectType)} {context.firebase.projectId && Firebase: {context.firebase.projectId}} + {context.missing.length > 0 && ( + + Will be handled during installation: + {context.missing.map((item) => ( + + • {item} + + ))} + + )} + + + ); +} + +function IosSetupAutoPhase({ onContinue }: { onContinue: () => void }): React.ReactElement { + useEffect(() => { + onContinue(); + }, [onContinue]); + + return ( + + ○ + iOS setup will continue during installation. + + - Starting SDK installation... + Proceeding to final confirmation... ); @@ -286,18 +340,7 @@ export function InstallPreparationUI({ } setContext(ctx); - - // Determine which setup phase to show - if (!ctx.firebase.configured && ctx.firebase.needed) { - setPhase('firebase_check'); - } else if (!ctx.ios.entitlementsConfigured && ctx.ios.needed) { - setPhase('ios_check'); - } else if (ctx.ready) { - setPhase('ready'); - } else { - // Some setup missing but not critical - show status - setPhase('firebase_check'); - } + setPhase(getInitialPreparationPhase(ctx)); } check(); @@ -349,16 +392,12 @@ export function InstallPreparationUI({ await saveSetupStatus(projectPath, updatedFirebase, context.ios); // Move to next phase - if (context.ios.needed && !context.ios.entitlementsConfigured) { - setPhase('ios_check'); - } else { - setPhase('ready'); - } + setPhase(getPostFirebasePhase(updatedContext)); }, [context, projectPath], ); - const handleIosSkip = useCallback(() => { + const handleIosAutoContinue = useCallback(() => { setPhase('ready'); }, []); @@ -369,7 +408,7 @@ export function InstallPreparationUI({ if (!context.firebase.configured && context.firebase.needed) { setPhase('firebase_setup'); } else if (context.ios.needed && !context.ios.entitlementsConfigured) { - setPhase('ios_check'); + setPhase('ios_setup'); } else { setPhase('ready'); } @@ -416,11 +455,8 @@ export function InstallPreparationUI({ return ; case 'ios_setup': - // For now, skip iOS setup wizard (complex integration) - // The agent will handle iOS setup if needed if (!context) return ; - handleIosSkip(); - return ; + return ; case 'ready': if (!context) return ; diff --git a/src/ui/components/ProjectSelector.tsx b/src/ui/components/ProjectSelector.tsx index 1fa2a1b..da39f08 100644 --- a/src/ui/components/ProjectSelector.tsx +++ b/src/ui/components/ProjectSelector.tsx @@ -3,16 +3,18 @@ import type React from 'react'; import { useEffect, useMemo, useState } from 'react'; import type { Organization, Project } from '@/lib/api'; -interface OrgWithProjects { +export interface OrgWithProjects { org: Organization; projects: Project[]; } -interface FlattenedProject { +export interface FlattenedProject { project: Project; org: Organization; } +const PROJECT_SORTER: Intl.Collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + export interface ProjectSelectorProps { organizations: OrgWithProjects[]; onSelect: (project: Project, org: Organization) => void; @@ -35,6 +37,50 @@ function flattenProjects(organizations: OrgWithProjects[]): FlattenedProject[] { return flattened; } +function compareProjectsAsc(a: FlattenedProject, b: FlattenedProject): number { + const byProjectName = PROJECT_SORTER.compare(a.project.name, b.project.name); + if (byProjectName !== 0) return byProjectName; + + const byOrgName = PROJECT_SORTER.compare(a.org.name, b.org.name); + if (byOrgName !== 0) return byOrgName; + + return PROJECT_SORTER.compare(a.project.id, b.project.id); +} + +export function getSortedProjects(organizations: OrgWithProjects[]): FlattenedProject[] { + return flattenProjects(organizations).sort(compareProjectsAsc); +} + +export function filterProjectsByQuery( + projects: FlattenedProject[], + query: string, +): FlattenedProject[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return projects; + + return projects.filter((item) => item.project.name.toLowerCase().includes(normalizedQuery)); +} + +function applySearchInput( + input: string, + key: { backspace?: boolean; delete?: boolean; space?: boolean }, + setSearchQuery: React.Dispatch>, +): boolean { + if (key.backspace || key.delete) { + setSearchQuery((prev) => prev.slice(0, -1)); + return true; + } + if (key.space) { + setSearchQuery((prev) => `${prev} `); + return true; + } + if (input && !input.startsWith('\u001b')) { + setSearchQuery((prev) => `${prev}${input}`); + return true; + } + return false; +} + export const ProjectSelector: React.FC = ({ organizations, onSelect, @@ -42,23 +88,19 @@ export const ProjectSelector: React.FC = ({ workspacePath, showSkip = true, }) => { - const flattenedProjects = useMemo(() => flattenProjects(organizations), [organizations]); + const sortedProjects = useMemo(() => getSortedProjects(organizations), [organizations]); + const [searchQuery, setSearchQuery] = useState(''); + const filteredProjects = useMemo( + () => filterProjectsByQuery(sortedProjects, searchQuery), + [sortedProjects, searchQuery], + ); const [selectedIndex, setSelectedIndex] = useState(0); // Calculate visible window for scrolling (show max 10 items) const maxVisible = 10; - const totalItems = flattenedProjects.length; + const totalItems = filteredProjects.length; const halfWindow = Math.floor(maxVisible / 2); - // Clamp selectedIndex when list changes - useEffect(() => { - setSelectedIndex((prev) => { - if (totalItems <= 0) return 0; - if (prev >= totalItems) return totalItems - 1; - return prev; - }); - }, [totalItems]); - let startIndex = 0; let endIndex = maxVisible; @@ -77,32 +119,54 @@ export const ProjectSelector: React.FC = ({ endIndex = totalItems; } - const visibleProjects = flattenedProjects.slice(startIndex, endIndex); + const visibleProjects = filteredProjects.slice(startIndex, endIndex); - useInput((_input, key) => { - // Handle empty list - only Enter/Esc work + useInput((input, key) => { + // Handle empty state differently for no projects vs no search matches. if (totalItems === 0) { - if (key.return || (showSkip && key.escape)) { + if (sortedProjects.length === 0 && (key.return || (showSkip && key.escape))) { onSkip(); + return; } + if (showSkip && key.escape) { + onSkip(); + return; + } + applySearchInput(input, key, setSearchQuery); return; } if (key.upArrow) { setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)); - } else if (key.downArrow) { + return; + } + if (key.downArrow) { setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)); - } else if (key.return) { - const selected = flattenedProjects[selectedIndex]; + return; + } + if (key.return) { + const selected = filteredProjects[selectedIndex]; if (selected) { onSelect(selected.project, selected.org); } - } else if (showSkip && key.escape) { + return; + } + if (showSkip && key.escape) { onSkip(); + return; } + applySearchInput(input, key, setSearchQuery); }); - if (flattenedProjects.length === 0) { + useEffect(() => { + setSelectedIndex((prev) => { + if (totalItems <= 0) return 0; + if (prev >= totalItems) return 0; + return prev; + }); + }, [totalItems]); + + if (sortedProjects.length === 0) { return ( No projects available to link. @@ -115,6 +179,29 @@ export const ProjectSelector: React.FC = ({ ); } + if (filteredProjects.length === 0) { + return ( + + + Select a project to link to this workspace: + + + {workspacePath} + + + Search: + {searchQuery || '(type to search)'} + + No matching projects found. + + + Type to search · Backspace to clear{showSkip ? ' · Esc to skip' : ''} + + + + ); + } + return ( @@ -123,6 +210,10 @@ export const ProjectSelector: React.FC = ({ {workspacePath} + + Search: + {searchQuery || '(type to search)'} + {startIndex > 0 && ( @@ -151,7 +242,9 @@ export const ProjectSelector: React.FC = ({ )} - ↑↓ to navigate · Enter to select{showSkip ? ' · Esc to skip' : ''} + + Type to search · ↑↓ to navigate · Enter to select{showSkip ? ' · Esc to skip' : ''} + ); diff --git a/src/ui/components/__tests__/firebase-wizard-transitions.test.ts b/src/ui/components/__tests__/firebase-wizard-transitions.test.ts new file mode 100644 index 0000000..92d9f23 --- /dev/null +++ b/src/ui/components/__tests__/firebase-wizard-transitions.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from 'bun:test'; +import { + type ExtendedWizardPhase, + PHASE_TRANSITIONS, + transition, +} from '../firebase-wizard-transitions'; + +describe('PHASE_TRANSITIONS', () => { + test('all target phases are valid ExtendedWizardPhase values', () => { + // Collect all phases that appear as targets + const targetPhases = new Set(); + for (const events of Object.values(PHASE_TRANSITIONS)) { + if (events) { + for (const target of Object.values(events)) { + targetPhases.add(target); + } + } + } + + // Collect all phases that appear as sources (keys in PHASE_TRANSITIONS) + // plus 'complete' which is a terminal state + const sourcePhases = new Set(Object.keys(PHASE_TRANSITIONS)); + sourcePhases.add('complete'); + + // Every target phase must be either a source phase or 'complete' + for (const target of targetPhases) { + expect(sourcePhases.has(target)).toBe(true); + } + }); + + test('error phase has retry transition', () => { + expect(PHASE_TRANSITIONS.error?.retry).toBe('detecting'); + }); + + test('complete phase has no transitions', () => { + expect(PHASE_TRANSITIONS.complete).toBeUndefined(); + }); +}); + +describe('transition()', () => { + test('returns correct target phase for valid transitions', () => { + expect(transition('detecting', 'success')).toBe('status'); + expect(transition('detecting', 'error')).toBe('error'); + expect(transition('status', 'continue')).toBe('menu'); + expect(transition('menu', 'done')).toBe('complete'); + }); + + test('throws on invalid event', () => { + expect(() => transition('detecting', 'nonexistent')).toThrow( + 'Invalid phase transition: detecting + "nonexistent"', + ); + }); + + test('throws on phase with no transitions', () => { + expect(() => transition('complete' as ExtendedWizardPhase, 'anything')).toThrow( + 'Invalid phase transition: complete + "anything"', + ); + }); + + test('error message includes valid events', () => { + try { + transition('detecting', 'bad'); + expect(true).toBe(false); // Should not reach + } catch (err) { + expect((err as Error).message).toContain('Valid events: success, error'); + } + }); +}); + +describe('flow: download → sender config → SA → complete', () => { + test('happy path: download → check sender config (not registered) → SA setup → register → complete', () => { + // Download completes + expect(transition('downloading', 'success')).toBe('checking_sender_config'); + // Sender config not registered + expect(transition('checking_sender_config', 'not_registered')).toBe('service_account_menu'); + // User pastes SA JSON + expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); + // Save SA JSON + expect(transition('paste_service_account', 'save')).toBe('saving_service_account'); + // Register sender config (has clixProjectId) + expect(transition('saving_service_account', 'register')).toBe('registering_sender_config'); + // Registration completes + expect(transition('registering_sender_config', 'complete')).toBe('complete'); + }); + + test('sender config already registered → complete', () => { + expect(transition('downloading', 'success')).toBe('checking_sender_config'); + expect(transition('checking_sender_config', 'registered')).toBe('sender_config_registered'); + expect(transition('sender_config_registered', 'continue')).toBe('complete'); + }); + + test('no clixProjectId → skip sender config check → SA setup → complete without register', () => { + expect(transition('downloading', 'success')).toBe('checking_sender_config'); + // Skip because no clixProjectId + expect(transition('checking_sender_config', 'skip')).toBe('service_account_menu'); + expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); + expect(transition('paste_service_account', 'save')).toBe('saving_service_account'); + // No clixProjectId → complete directly + expect(transition('saving_service_account', 'complete')).toBe('complete'); + }); + + test('sender config API error → still goes to SA menu', () => { + expect(transition('checking_sender_config', 'error')).toBe('service_account_menu'); + }); +}); + +describe('flow: initial detection → menu → done', () => { + test('detect → status → menu → done', () => { + expect(transition('detecting', 'success')).toBe('status'); + expect(transition('status', 'continue')).toBe('menu'); + expect(transition('menu', 'done')).toBe('complete'); + }); + + test('detect → status → menu → skip', () => { + expect(transition('detecting', 'success')).toBe('status'); + expect(transition('status', 'continue')).toBe('menu'); + expect(transition('menu', 'skip')).toBe('complete'); + }); + + test('detect error → retry', () => { + expect(transition('detecting', 'error')).toBe('error'); + expect(transition('error', 'retry')).toBe('detecting'); + }); +}); + +describe('flow: download authentication', () => { + test('auth → select project → select apps → download', () => { + expect(transition('authenticating', 'select_project')).toBe('select_project'); + expect(transition('select_project', 'select_android_app')).toBe('select_android_app'); + expect(transition('select_android_app', 'select_ios_app')).toBe('select_ios_app'); + // select_ios_app triggers download via ref (not a phase transition) + }); + + test('auth → no projects → open console → menu', () => { + expect(transition('authenticating', 'no_projects')).toBe('no_projects'); + expect(transition('no_projects', 'open_console')).toBe('menu'); + }); + + test('auth → no projects → GCP → add firebase', () => { + expect(transition('authenticating', 'no_projects')).toBe('no_projects'); + // Note: no_projects → select_gcp is indirect (goes through authenticating again) + }); + + test('auth error', () => { + expect(transition('authenticating', 'error')).toBe('error'); + }); +}); + +describe('flow: app creation', () => { + test('no apps → create android → creating → needs iOS → no apps found again', () => { + expect(transition('no_apps_found', 'create_android')).toBe('create_android_app'); + // create_android_app submission triggers handleCreateApp directly (not via transition) + // After creating: needs iOS → go back to no_apps_found + expect(transition('creating_app', 'no_apps_found')).toBe('no_apps_found'); + }); + + test('no apps → create ios', () => { + expect(transition('no_apps_found', 'create_ios')).toBe('create_ios_app'); + }); + + test('no apps → cancel → back to project selection', () => { + expect(transition('no_apps_found', 'cancel')).toBe('select_project'); + }); + + test('app creation error', () => { + expect(transition('creating_app', 'error')).toBe('error'); + }); +}); + +describe('flow: service account menu', () => { + test('open console → paste SA', () => { + expect(transition('service_account_menu', 'open_console')).toBe('paste_service_account'); + }); + + test('paste json → paste SA', () => { + expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); + }); + + test('skip SA → status', () => { + expect(transition('service_account_menu', 'skip')).toBe('status'); + }); + + test('cancel paste → back to SA menu', () => { + expect(transition('paste_service_account', 'cancel')).toBe('service_account_menu'); + }); + + test('save error', () => { + expect(transition('saving_service_account', 'error')).toBe('error'); + }); +}); + +describe('flow: menu → validate', () => { + test('validate → success → status', () => { + expect(transition('menu', 'validate')).toBe('validating'); + expect(transition('validating', 'success')).toBe('status'); + }); + + test('validate → error', () => { + expect(transition('menu', 'validate')).toBe('validating'); + expect(transition('validating', 'error')).toBe('error'); + }); +}); + +describe('flow: menu → redetect', () => { + test('redetect all → detecting', () => { + expect(transition('menu', 'redetect')).toBe('detecting'); + }); + + test('redetect platform → detecting', () => { + expect(transition('menu', 'redetect_platform')).toBe('detecting'); + }); +}); + +describe('flow: GCP project', () => { + test('select GCP → add firebase', () => { + expect(transition('select_gcp_project', 'adding_firebase')).toBe('adding_firebase'); + }); + + test('select GCP error', () => { + expect(transition('select_gcp_project', 'error')).toBe('error'); + }); + + test('add firebase error', () => { + expect(transition('adding_firebase', 'error')).toBe('error'); + }); +}); diff --git a/src/ui/components/firebase-wizard-transitions.ts b/src/ui/components/firebase-wizard-transitions.ts new file mode 100644 index 0000000..fa0d3e0 --- /dev/null +++ b/src/ui/components/firebase-wizard-transitions.ts @@ -0,0 +1,193 @@ +import type { WizardPhase } from '@/lib/services/firebase'; + +/** + * Extended wizard phase for download flow. + */ +export type ExtendedWizardPhase = + | WizardPhase + | 'authenticating' + | 'select_project' + | 'select_android_app' + | 'select_ios_app' + | 'downloading' + | 'no_apps_found' + | 'create_android_app' + | 'create_ios_app' + | 'creating_app' + | 'no_projects' + | 'select_gcp_project' + | 'adding_firebase' + | 'checking_sender_config' + | 'sender_config_registered' + | 'service_account_menu' + | 'paste_service_account' + | 'saving_service_account' + | 'registering_sender_config'; + +/** + * Phase transition event type. + * Each key represents a named event that triggers a transition. + */ +type TransitionEvent = string; + +/** + * Centralized phase transition map. + * + * Every valid phase transition is defined here. This makes the entire + * wizard flow visible in one place and prevents invalid transitions. + * + * Flow overview: + * detecting → status → menu → (various actions) + * menu/download → authenticating → select_project → select_*_app → downloading + * downloading → checking_sender_config → service_account_menu → paste → saving → complete + * + * Adding a new phase: + * 1. Add the phase to ExtendedWizardPhase type + * 2. Add transition rules here + * 3. Add the phase component and rendering case + */ +export const PHASE_TRANSITIONS: Partial< + Record> +> = { + // === Initial Detection === + detecting: { + success: 'status', + error: 'error', + }, + + // === Status & Menu === + status: { + continue: 'menu', + }, + menu: { + redetect: 'detecting', + redetect_platform: 'detecting', + validate: 'validating', + download: 'authenticating', + setup_service_account: 'service_account_menu', + done: 'complete', + skip: 'complete', + }, + validating: { + success: 'status', + error: 'error', + }, + + // === Download / Auth Flow === + authenticating: { + no_projects: 'no_projects', + select_project: 'select_project', + select_gcp_project: 'select_gcp_project', + error: 'error', + }, + select_project: { + no_apps_found: 'no_apps_found', + select_android_app: 'select_android_app', + select_ios_app: 'select_ios_app', + error: 'error', + }, + select_android_app: { + select_ios_app: 'select_ios_app', + // note: also triggers download via ref (not a phase transition) + }, + select_ios_app: { + // note: triggers download via ref (not a phase transition) + }, + downloading: { + success: 'checking_sender_config', + error: 'error', + }, + + // === No Apps / App Creation === + no_apps_found: { + create_android: 'create_android_app', + create_ios: 'create_ios_app', + cancel: 'select_project', + }, + create_android_app: { + submit: 'creating_app', + cancel: 'select_project', + }, + create_ios_app: { + submit: 'creating_app', + cancel: 'select_project', + }, + creating_app: { + no_apps_found: 'no_apps_found', + select_ios_app: 'select_ios_app', + error: 'error', + // note: also triggers download via ref (not a phase transition) + }, + + // === No Projects / GCP === + no_projects: { + open_console: 'menu', + select_gcp: 'authenticating', + }, + select_gcp_project: { + adding_firebase: 'adding_firebase', + error: 'error', + }, + adding_firebase: { + error: 'error', + // note: on success, triggers project selection via ref + }, + + // === Sender Config Check === + checking_sender_config: { + registered: 'sender_config_registered', + not_registered: 'service_account_menu', + skip: 'service_account_menu', + error: 'service_account_menu', + }, + sender_config_registered: { + continue: 'complete', + }, + + // === Service Account Setup === + service_account_menu: { + open_console: 'paste_service_account', + paste_json: 'paste_service_account', + skip: 'status', + error: 'error', + }, + paste_service_account: { + save: 'saving_service_account', + cancel: 'service_account_menu', + }, + saving_service_account: { + register: 'registering_sender_config', + complete: 'complete', + error: 'error', + }, + registering_sender_config: { + complete: 'complete', + }, + + // === Terminal States === + error: { + retry: 'detecting', + // note: retry with re-auth goes through handleDownload (not a simple transition) + }, + // 'complete' has no outgoing transitions +}; + +/** + * Perform a validated phase transition. + * + * @param from - Current phase + * @param event - Named event triggering the transition + * @returns Target phase + * @throws Error if the transition is not defined in PHASE_TRANSITIONS + */ +export function transition(from: ExtendedWizardPhase, event: TransitionEvent): ExtendedWizardPhase { + const transitions = PHASE_TRANSITIONS[from]; + const target = transitions?.[event]; + if (!target) { + throw new Error( + `Invalid phase transition: ${from} + "${event}". ` + + `Valid events: ${transitions ? Object.keys(transitions).join(', ') : 'none'}`, + ); + } + return target; +} From 34650a7810683853b8aae1e9672464459bdb8992 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 16 Feb 2026 17:30:29 +0900 Subject: [PATCH 05/30] chore: ignore local ralph-tui and llm-council artifacts --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 8a1acbe..bcaf072 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ android/app/google-services.json # Clix CLI local config .clix/ + + +.ralph-tui +llm-council \ No newline at end of file From 556e184d960bcdaa84084ef57354f4922c3f7c3d Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 16 Feb 2026 14:30:32 -0800 Subject: [PATCH 06/30] docs: improve AGENTS.md with agent directives, code examples, and conditional workflows Add Agent Behavior, Conditional Workflows, and Common Mistakes sections inspired by OpenAI Codex and OpenCode AGENTS.md best practices. Expand TypeScript Patterns with BAD/GOOD code examples and Testing with mock patterns. Condense FirebaseWizard, Safe Rendering, OAuth, and UI Architecture sections. Merge Code Quality and Commits into single section. --- AGENTS.md | 244 +++++++++++++++++++++++++++--------------------------- 1 file changed, 123 insertions(+), 121 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed1bca1..1e8284b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Clix CLI is an interactive AI-powered assistant for Clix SDK integration. Built with React/Ink for terminal UI, it supports multiple AI agents (Claude, Codex, Gemini, OpenCode, Cursor, Copilot) with streaming responses, slash commands, and pre-built skills for SDK workflows. +## Agent Behavior + +- After any code change, run `bun run check && bun test` automatically. Do not ask permission to run tests. +- Before introducing a new pattern, search the codebase for existing implementations first. +- Prefer automation over confirmation — run tests, fix lint issues, and verify results yourself. +- Only modify files directly related to the task. Do not refactor or "improve" surrounding code. +- When fixing lint/test failures, fix ALL issues in one pass, not incrementally. +- Do not create helper functions that are referenced only once. + ## Commands ```bash @@ -14,11 +23,32 @@ bun run dev # Run CLI from source bun run build # Bundle for distribution bun test # Run all tests bun test path/to/file.test.ts # Run single test -bun run check # Lint + typecheck +bun run check # Lint + typecheck (Biome + tsc) ``` Debug mode: `DEBUG=1 bun run src/cli.tsx ` +## Conditional Workflows + +If you change X, do Y: + +- **Slash commands added/removed/renamed** → Update `README.md` and `llms.txt` per the sync table below. Run `bun test src/ui/chat/components/__tests__/slash-command-menu.test.ts` to verify registry sync. +- **`firebase-wizard-transitions.ts` modified** → Run `bun test src/ui/components/__tests__/firebase-wizard-transitions.test.ts` +- **New UI command added** → Use `safeRender()` (not Ink's `render()`), see [Safe Rendering](#safe-rendering) +- **New React component added** → Include `useCancelInput` hook for ESC/Ctrl+C support, see [Cancel Input](#cancel-input-handling) +- **Biome config (`biome.json`) changed** → Run `bun run lint:fix` to apply new rules across the codebase +- **Command registry (`src/lib/commands/registry.ts`) changed** → Verify slash command menu test passes + +**Documentation Sync**: When making changes to the following, update both `README.md` and `llms.txt`: + +| Change Type | README.md Section | llms.txt Section | +| ------------------------------- | -------------------------------------- | ---------------------------------------------------- | +| Slash commands | "Slash Commands" table | "Slash Commands" + "Slash Commands Reference" | +| AI agents | "Prerequisites" table, "clix agent" | "Prerequisites", "Agent System", "For AI Assistants" | +| Skills (interactive/autonomous) | "Interactive Skills", "Slash Commands" | "Interactive Skills", "Autonomous Commands" | +| CLI commands | "Commands" section | "Commands" section | +| Config paths | N/A | "Configuration" section | + ## Architecture ### Entry & Routing @@ -41,26 +71,9 @@ Three command types in `src/lib/commands/types.ts`: - `LocalJSXCommand` - Returns React component (e.g., `/help`, `/debug`) - `PromptCommand` - Sends prompt to AI (skill commands) -Registry and lookup: `src/lib/commands/registry.ts` - -Session reset command: use `/new` to start a new session; `/clear` is a legacy alias. - -Slash command suggestions: `src/ui/chat/components/SlashCommandMenu.tsx` derives its list (canonical names only, no aliases) from the registry (no hardcoded system command list). Sync is enforced by `src/ui/chat/components/__tests__/slash-command-menu.test.ts`. Use `isHidden: true` to hide a command from help + suggestions. +Registry and lookup: `src/lib/commands/registry.ts`. Session reset: `/new` (legacy alias: `/clear`). -**Documentation Sync**: When making changes to the following, update both `README.md` and `llms.txt`: - -| Change Type | README.md Section | llms.txt Section | -| ------------------------------- | -------------------------------------- | ---------------------------------------------------- | -| Slash commands | "Slash Commands" table | "Slash Commands" + "Slash Commands Reference" | -| AI agents | "Prerequisites" table, "clix agent" | "Prerequisites", "Agent System", "For AI Assistants" | -| Skills (interactive/autonomous) | "Interactive Skills", "Slash Commands" | "Interactive Skills", "Autonomous Commands" | -| CLI commands | "Commands" section | "Commands" section | -| Config paths | N/A | "Configuration" section | - -Key files: - -- `README.md` - User-facing documentation (concise) -- `llms.txt` - AI assistant reference (comprehensive) +Slash command suggestions in `src/ui/chat/components/SlashCommandMenu.tsx` derive from the registry (no hardcoded lists). Use `isHidden: true` to hide a command from help + suggestions. ### Chat UI @@ -68,29 +81,16 @@ Key files: ### FirebaseWizard State Machine -`src/ui/components/FirebaseWizard.tsx` - Multi-phase wizard for Firebase configuration. Uses a centralized state machine pattern. - -**Phase transition map**: `src/ui/components/firebase-wizard-transitions.ts` - -- `PHASE_TRANSITIONS` - All valid phase transitions in one map -- `transition(from, event)` - Validated transition function (throws on invalid transitions) -- `ExtendedWizardPhase` - Union type of all 24 phases +`src/ui/components/FirebaseWizard.tsx` - Multi-phase wizard using centralized state machine in `src/ui/components/firebase-wizard-transitions.ts`. **Rules when modifying FirebaseWizard**: 1. **Update `PHASE_TRANSITIONS` first** - Add/modify transition rules in the map before changing handlers 2. **Use `transition()` for all `setPhase()` calls** - Never call `setPhase('phase')` directly; use `setPhase(transition('currentPhase', 'event'))` instead -3. **One `setPhase` per handler** - Avoid multiple `setPhase()` calls in a single async handler. If a handler needs multiple steps, split into separate phases with `useEffect` -4. **"Direct set" exceptions** - Only use direct `setPhase()` when the source phase is ambiguous (entry points from multiple phases). Add a `// Direct set:` comment explaining why +3. **One `setPhase` per handler** - Avoid multiple `setPhase()` calls in a single async handler. Split into separate phases with `useEffect` +4. **"Direct set" exceptions** - Only use direct `setPhase()` when the source phase is ambiguous (entry points from multiple phases). Add a `// Direct set:` comment -**Flow overview**: -``` -detecting → status → menu → (actions) -menu/download → authenticating → select_project → select_*_app → downloading -downloading → checking_sender_config → service_account_menu → paste → saving → complete -``` - -**Tests**: `src/ui/components/__tests__/firebase-wizard-transitions.test.ts` (34 tests, 100% coverage on transition file) +Tests: `src/ui/components/__tests__/firebase-wizard-transitions.test.ts` ### Skills @@ -109,11 +109,48 @@ downloading → checking_sender_config → service_account_menu → paste → sa ## TypeScript Patterns -**Avoid `any`**: Use `unknown` with type narrowing instead. If you need to assert types, ensure it's truly necessary. +**Avoid `any`** — use `unknown` with type narrowing: -**Type guards**: Use existing guards like `isLocalCommand()`, `isLocalJSXCommand()`, `isPromptCommand()` in `src/lib/commands/types.ts:119-135`. +```typescript +// BAD +function process(data: any) { return data.value; } + +// GOOD +function process(data: unknown): string { + if (typeof data === 'object' && data !== null && 'value' in data) { + return String((data as { value: unknown }).value); + } + throw new Error('Invalid data'); +} +``` + +**No non-null assertions** — use guard checks: -**Prefer plain objects with interfaces** over classes where possible. Use ES module exports for encapsulation. +```typescript +// BAD +const name = user!.name; + +// GOOD +if (!user) throw new Error('User not found'); +const name = user.name; +``` + +**Prefer interfaces + plain objects** over classes: + +```typescript +// BAD +class Config { constructor(public name: string, public value: number) {} } + +// GOOD +interface Config { name: string; value: number } +const config: Config = { name: 'foo', value: 42 }; +``` + +**Additional rules**: +- Use `import type { X }` for type-only imports (Biome `useImportType` enforced) +- Always `const` over `let` when variable is not reassigned +- Use existing type guards: `isLocalCommand()`, `isLocalJSXCommand()`, `isPromptCommand()` in `src/lib/commands/types.ts:119-135` +- Error handling: use `ClixError` subclasses from `src/lib/errors/` (AgentError, ConfigError, NetworkError, ValidationError, etc.) ## React/Ink Patterns @@ -146,75 +183,36 @@ import { isCtrlCInput } from '@/ui/hooks'; if (isCtrlCInput(input, key)) { ... } ``` -**New Component Checklist:** -1. Import `useCancelInput` from `@/ui/hooks` -2. Add cancel handler with appropriate `isActive` condition -3. For selector-based components, extend `GenericSelector` (has built-in cancel) -4. Update help text to include `Esc/Ctrl+C to cancel` - -### Safe Rendering (Anti-Flicker) - -**Always use `safeRender()` instead of Ink's `render()`** to prevent terminal flickering. +**New components**: Import `useCancelInput`, add cancel handler with `isActive` condition. For selector-based components, extend `GenericSelector` (has built-in cancel). -**Background**: When rendered content approaches terminal height, Ink causes "unintentional scrolling" resulting in screen flicker (Ink issue #450). The solution is to limit rendering height to `process.stdout.rows - 1`. +### Safe Rendering -**Implementation**: - -- `src/ui/utils/safeRender.tsx` - Wrapper around Ink's `render()` -- `src/ui/components/SafeHeightContainer.tsx` - Height-limiting container - -**Usage**: +**Always use `safeRender()` instead of Ink's `render()`** to prevent terminal flickering (Ink issue #450). ```typescript -// Before (DO NOT USE) +// BAD — DO NOT USE import { render } from 'ink'; render(, { incrementalRendering: true }); -// After (ALWAYS USE THIS) +// GOOD — ALWAYS USE THIS import { safeRender } from '@/ui/utils/safeRender'; safeRender(); ``` -**Features**: - -- Automatically wraps components with `SafeHeightContainer` -- `incrementalRendering: true` applied by default -- `rerender()` also applies safe height automatically -- Use `{ disableSafeHeight: true }` option only if full terminal height is required - -**When adding new commands**: Always use `safeRender()` for consistent flicker-free rendering. - ### UI Component Architecture -Rules for separating Command mode and Interactive mode: - **Execution Modes**: - **Command mode**: `clix ` - Single execution, auto-exits (`oneShot: true`) - **Interactive mode**: `clix` - Persistent conversation session (`oneShot: false`) -**Path Alias**: - -- `@/` = `src/` directory -- Example: `import { Header } from '@/ui/components/Header'` - -**Shared Components (`@/ui/components/`)**: +**Path Alias**: `@/` = `src/` directory (e.g., `import { Header } from '@/ui/components/Header'`) -- `ToolCallDisplay` - Tool execution status (used by both modes) -- `StatusMessage` - Status messages (loading/success/error) -- `Header` - Simple title header -- `AgentSelector`, `NoAgentGuide`, etc. +**Directory rules**: -**Interactive Mode Only (`@/ui/chat/components/`)**: - -- `ChatHeader`, `ChatFooter`, `ChatInput` -- `MessageList`, `UserMessage`, `AgentMessage` -- `SlashCommandMenu` - -**Command Mode Only (`@/ui/`)**: - -- `AgentExecutionUI.tsx` - AI agent-based command execution UI (install, doctor, etc.) -- `ConfigUI.tsx` - Configuration UI +- `@/ui/components/` - Shared components (used by both modes) +- `@/ui/chat/components/` - Interactive mode only +- `@/ui/` (root level) - Command mode only (`AgentExecutionUI.tsx`, `ConfigUI.tsx`) **Rules**: @@ -226,62 +224,66 @@ Rules for separating Command mode and Interactive mode: Tests use `bun:test` (`describe`, `test`, `expect`, `mock`). -- Unit tests: `src/lib/__tests__/` and `src/lib/**/__tests__/` +- Unit tests: `src/lib/__tests__/` and colocated `__tests__/` directories - E2E tests: `tests/e2e/` - Test utilities: `src/lib/__tests__/test-utils/` -**Mocking**: Use `mock.module()` for module mocks. See existing patterns in test files. Keep tests deterministic—avoid network calls. +**Test hierarchy**: First run the specific file test (`bun test path/to/file.test.ts`). If shared code changed, run full suite (`bun test`). -## Code Quality Requirements +**Mocking**: Use `mock.module()` for module mocks. Keep tests deterministic — avoid network calls. -**After every code change**, run the full test suite and fix ALL issues: +```typescript +import { describe, expect, mock, test } from 'bun:test'; -```bash -bun run check && bun test # Unit tests -bun run build && bun test tests/e2e/ # E2E tests (requires build first) +mock.module('@/lib/services/firebase/firebase-service', () => ({ + getFirebaseStatus: () => Promise.resolve({ configured: true }), +})); ``` -1. **Errors**: Must be fixed immediately - code cannot be committed with errors -2. **Warnings**: Must also be fixed - treat warnings as errors -3. **Unrelated issues**: If you encounter warnings/errors in files unrelated to your current change, fix them too -4. **E2E test failures**: Must be fixed even if seemingly unrelated - E2E tests verify integrated behavior +**Rules**: No `as any` in tests — use proper typing. Test files go in `__tests__/` directories adjacent to the code they test. -**Complexity warnings** (`noExcessiveCognitiveComplexity`): -- When a function exceeds complexity threshold (25), refactor it by extracting helper functions -- Common patterns: extract validation logic, split async operations, create focused sub-functions +## Code Quality & Commits -**Zero tolerance policy**: The codebase must have zero warnings, zero errors, AND all tests (unit + E2E) must pass after any change. +**Zero tolerance**: The codebase must have zero warnings, zero errors, AND all tests must pass after any change. -## Commits +**After every code change**: -Follow Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:` +```bash +bun run check && bun test # Lint + typecheck + unit tests +bun run build && bun test tests/e2e/ # E2E tests (requires build first) +``` Before committing: `bun run check && bun test && bun run build && bun test tests/e2e/` -**Important**: All lint, typecheck warnings, unit tests, and E2E tests must pass before committing. The codebase should have zero warnings, not just zero errors. +**Rules**: +1. **Errors**: Must be fixed immediately +2. **Warnings**: Treat as errors — fix them too +3. **Unrelated issues**: Fix warnings/errors in unrelated files if encountered +4. **E2E failures**: Must be fixed even if seemingly unrelated -## OAuth Callback URL Convention +**Complexity** (`noExcessiveCognitiveComplexity`, threshold: 25): Refactor by extracting helper functions — extract validation logic, split async operations, create focused sub-functions. -All browser-based OAuth flows use port **9005** and path **`/auth/callback`**, but the hostname varies by provider due to technical requirements. +**Commits**: Follow Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:` -**Rationale**: Using a fixed port and path simplifies OAuth provider configuration (Allowed Callback URLs). +## OAuth Callback URL Convention -**Implementation**: +All browser-based OAuth flows use port **9005** and path **`/auth/callback`**: -- Auth0 (Clix login): `http://localhost:9005/auth/callback` via `getCallbackUrl()` - see `src/lib/auth/pkce-flow.ts` -- Firebase/Google: `http://127.0.0.1:9005/auth/callback` via `getCallbackUrlIp()` - see `src/lib/services/firebase/oauth/config.ts` +- Auth0 (Clix login): `http://localhost:9005/auth/callback` — see `src/lib/auth/pkce-flow.ts` +- Firebase/Google: `http://127.0.0.1:9005/auth/callback` (loopback IP per RFC 8252) — see `src/lib/services/firebase/oauth/config.ts` -**Why different hostnames?** Desktop app OAuth (like Google OAuth for installed applications) requires the loopback IP address (`127.0.0.1`) rather than `localhost` per OAuth 2.0 for Native Apps (RFC 8252). Auth0 works with either, so it uses `localhost` for consistency. +Shared utilities in `src/lib/utils/oauth.ts`: `OAuthCallbackServer`, PKCE helpers, CSRF state generation. -**Shared utilities**: `src/lib/utils/oauth.ts` provides: +When adding new OAuth flows, use port 9005, path `/auth/callback`, and the shared `OAuthCallbackServer` class. -- `OAuthCallbackServer` - Local HTTP server for OAuth callbacks -- `OAUTH_CALLBACK_CONFIG.getCallbackUrl()` - Returns `localhost` variant -- `OAUTH_CALLBACK_CONFIG.getCallbackUrlIp()` - Returns `127.0.0.1` variant for desktop OAuth -- `generateCodeVerifier()`, `generateCodeChallenge()` - PKCE utilities -- `generateState()` - CSRF protection +## Common Mistakes -When adding new OAuth flows, use port 9005, path `/auth/callback`, the shared `OAuthCallbackServer` class, and pick the appropriate hostname method based on OAuth provider requirements. +- **Never** use `render()` from ink directly — use `safeRender()` from `@/ui/utils/safeRender` +- **Never** call `setPhase()` directly in FirebaseWizard — use `transition()` helper +- **Never** hardcode slash command lists — derive from registry (`getCommands()`) +- **Never** use `as any` type assertions — use `unknown` with type narrowing +- **Never** use non-null assertion (`!`) — add guard checks +- **Never** commit with failing tests or warnings — zero tolerance ## Security From 824b0c1bda855bcca6895def32770190c5e5cea7 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Tue, 17 Feb 2026 14:08:41 -0800 Subject: [PATCH 07/30] refactor(install): replace setup wizards with task-based flow Flatten install preparation into sequential leaf tasks for Firebase, APNS, iOS entitlements, and Notification Service Extension. Remove obsolete wizard/state-machine files and deprecated push setup aliases, and add dedicated task modules plus regression tests. Update related docs (README, llms, AGENTS) and local ignore entries for the new workflow. --- .gitignore | 2 +- AGENTS.md | 21 +- README.md | 37 +- llms.txt | 145 +- .../skill/__tests__/preparation.test.ts | 251 +- src/commands/skill/preparation.ts | 203 +- src/lib/__tests__/skills.test.ts | 17 + src/lib/commands/skills.ts | 1 - src/lib/embedded-skills.ts | 6 +- src/lib/push/types.ts | 10 +- src/lib/services/firebase/types.ts | 20 +- src/lib/skills.ts | 14 +- src/lib/skills/doctor/SKILL.md | 2 +- src/lib/skills/install/SKILL.md | 4 +- src/ui/chat/ChatApp.tsx | 25 +- src/ui/chat/hooks/useCommandHandler.ts | 14 - src/ui/chat/hooks/useOverlays.ts | 40 - .../components/FirebaseConfigFilesSetup.tsx | 660 +++++ .../FirebaseServiceAccountSetup.tsx | 433 ++++ src/ui/components/FirebaseWizard.tsx | 2278 ----------------- src/ui/components/GenericSelector.tsx | 149 +- src/ui/components/InstallPreparationUI.tsx | 2239 ++++++++++++++-- src/ui/components/IosSetupFlow.tsx | 293 --- src/ui/components/ProjectSelector.tsx | 23 +- .../__tests__/file-input-utils.test.ts | 44 + .../firebase-wizard-transitions.test.ts | 226 -- .../__tests__/generic-selector.test.ts | 76 + .../install-preparation-flow.test.ts | 197 ++ .../__tests__/project-selector.test.ts | 109 + src/ui/components/file-input-utils.ts | 36 + .../components/firebase-wizard-transitions.ts | 193 -- .../components/install-preparation-tasks.ts | 64 + .../ios-setup/IosEntitlementsTask.tsx | 12 + .../NotificationExtensionTasks.tsx} | 208 +- .../PushSetupTasks.tsx} | 1124 ++++---- 35 files changed, 4811 insertions(+), 4365 deletions(-) create mode 100644 src/ui/components/FirebaseConfigFilesSetup.tsx create mode 100644 src/ui/components/FirebaseServiceAccountSetup.tsx delete mode 100644 src/ui/components/FirebaseWizard.tsx delete mode 100644 src/ui/components/IosSetupFlow.tsx create mode 100644 src/ui/components/__tests__/file-input-utils.test.ts delete mode 100644 src/ui/components/__tests__/firebase-wizard-transitions.test.ts create mode 100644 src/ui/components/__tests__/generic-selector.test.ts create mode 100644 src/ui/components/__tests__/install-preparation-flow.test.ts create mode 100644 src/ui/components/__tests__/project-selector.test.ts create mode 100644 src/ui/components/file-input-utils.ts delete mode 100644 src/ui/components/firebase-wizard-transitions.ts create mode 100644 src/ui/components/install-preparation-tasks.ts create mode 100644 src/ui/components/ios-setup/IosEntitlementsTask.tsx rename src/ui/components/{GuidedSetupWizard.tsx => notification-extension-setup/NotificationExtensionTasks.tsx} (54%) rename src/ui/components/{PushSetupWizard.tsx => push-setup/PushSetupTasks.tsx} (62%) diff --git a/.gitignore b/.gitignore index bcaf072..209af4d 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,6 @@ android/app/google-services.json # Clix CLI local config .clix/ - +.omc .ralph-tui llm-council \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1e8284b..1151d78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,6 @@ Debug mode: `DEBUG=1 bun run src/cli.tsx ` If you change X, do Y: - **Slash commands added/removed/renamed** → Update `README.md` and `llms.txt` per the sync table below. Run `bun test src/ui/chat/components/__tests__/slash-command-menu.test.ts` to verify registry sync. -- **`firebase-wizard-transitions.ts` modified** → Run `bun test src/ui/components/__tests__/firebase-wizard-transitions.test.ts` - **New UI command added** → Use `safeRender()` (not Ink's `render()`), see [Safe Rendering](#safe-rendering) - **New React component added** → Include `useCancelInput` hook for ESC/Ctrl+C support, see [Cancel Input](#cancel-input-handling) - **Biome config (`biome.json`) changed** → Run `bun run lint:fix` to apply new rules across the codebase @@ -79,18 +78,17 @@ Slash command suggestions in `src/ui/chat/components/SlashCommandMenu.tsx` deriv `src/ui/chat/ChatApp.tsx` - Main component with context provider. State split into focused hooks under `src/ui/chat/hooks/`. -### FirebaseWizard State Machine +### Install Preparation Tasks -`src/ui/components/FirebaseWizard.tsx` - Multi-phase wizard using centralized state machine in `src/ui/components/firebase-wizard-transitions.ts`. +`src/ui/components/InstallPreparationUI.tsx` orchestrates setup as sequential 1-depth tasks. -**Rules when modifying FirebaseWizard**: - -1. **Update `PHASE_TRANSITIONS` first** - Add/modify transition rules in the map before changing handlers -2. **Use `transition()` for all `setPhase()` calls** - Never call `setPhase('phase')` directly; use `setPhase(transition('currentPhase', 'event'))` instead -3. **One `setPhase` per handler** - Avoid multiple `setPhase()` calls in a single async handler. Split into separate phases with `useEffect` -4. **"Direct set" exceptions** - Only use direct `setPhase()` when the source phase is ambiguous (entry points from multiple phases). Add a `// Direct set:` comment - -Tests: `src/ui/components/__tests__/firebase-wizard-transitions.test.ts` +- Task order and labels are defined in `src/ui/components/install-preparation-tasks.ts` +- Firebase, APNS, iOS entitlements, and Notification Extension are implemented as reusable task components under: + - `src/ui/components/FirebaseConfigFilesSetup.tsx` + - `src/ui/components/FirebaseServiceAccountSetup.tsx` + - `src/ui/components/push-setup/PushSetupTasks.tsx` + - `src/ui/components/ios-setup/IosEntitlementsTask.tsx` + - `src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx` ### Skills @@ -279,7 +277,6 @@ When adding new OAuth flows, use port 9005, path `/auth/callback`, and the share ## Common Mistakes - **Never** use `render()` from ink directly — use `safeRender()` from `@/ui/utils/safeRender` -- **Never** call `setPhase()` directly in FirebaseWizard — use `transition()` helper - **Never** hardcode slash command lists — derive from registry (`getCommands()`) - **Never** use `as any` type assertions — use `unknown` with type narrowing - **Never** use non-null assertion (`!`) — add guard checks diff --git a/README.md b/README.md index ac12624..a6a39f1 100644 --- a/README.md +++ b/README.md @@ -160,35 +160,16 @@ Analyze SDK integration status in your project. clix doctor ``` -### `clix ios-setup` +### iOS Push Preparation in `clix install` -Configure iOS capabilities, Notification Service Extension (NSE), and APNS key for the Clix SDK. +`clix install` runs required iOS push preparation tasks in order: +1. Firebase configuration files +2. APNS key setup in Firebase +3. Firebase Service Account registration +4. iOS entitlements +5. Notification Service Extension (NSE) -```bash -clix ios-setup -``` - -**What it does:** -1. **Phase 1 - Capabilities & Entitlements (Automatic):** - - Analyzes your iOS project structure - - Syncs capabilities with Apple Developer Portal (Push Notifications, App Groups) - - Creates/modifies entitlements files - -2. **Phase 2 - Extension Setup (Guided):** - - Auto-generates NSE files: - - `NotificationService.swift` with `ClixNotificationServiceExtension` - - `Info.plist` for extension - - Extension entitlements file - - Step-by-step guide for Xcode configuration: - - Creating extension target in Xcode - - Configuring build settings (`ENABLE_USER_SCRIPT_SANDBOXING` for Xcode 15+) - - Adding CocoaPods/SPM dependencies - -3. **Phase 3 - APNS Key Setup (Optional):** - - APNS authentication key (.p8 file) creation guide - - Firebase Console upload for push notification delivery - -**Note:** Phase 2 & 3 require manual action in Xcode, Apple Developer Portal, and Firebase Console. +The flow blocks installation until required setup steps are complete. ### `clix update` @@ -291,7 +272,6 @@ 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 and NSE | ### Interactive Skills @@ -311,7 +291,6 @@ Use these commands within the interactive chat (`clix`): | `/new` | /clear | Start a new session | | `/compact` | /c | Compress conversation history | | `/agent` | /a | List or switch agents | -| `/firebase` | | Check and configure Firebase credentials | | `/transfer` | /t | Transfer to agent CLI | | `/resume` | | Resume a previous session | | `/install-mcp` | /mcp | Install Clix MCP Server | diff --git a/llms.txt b/llms.txt index 7ab8cf0..1d82427 100644 --- a/llms.txt +++ b/llms.txt @@ -232,16 +232,15 @@ The interactive chat (`clix` command) is the primary way to interact with Clix C ### Slash Commands -Type `/` in the chat to see the autocomplete menu. All 20 slash commands are organized into three categories: +Type `/` in the chat to see the autocomplete menu. Slash commands are organized into three categories: -#### Autonomous Commands (4 commands) +#### Autonomous Commands (3 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 and Notification Service Extension (NSE) #### Interactive Skills (5 commands) @@ -253,7 +252,7 @@ These execute pre-built workflows from the `@clix-so/clix-agent-skills` package - `/personalization` - Personalization template creation and debugging - `/api-triggered-campaigns` - API-triggered campaign setup -#### System Commands (11 commands) +#### System Commands These are built-in commands for chat management and tools: @@ -261,7 +260,6 @@ These are built-in commands for chat management and tools: - `/new` (alias: `/clear`) - Start a new session (clear history) - `/compact` (alias: `/c`) - Compress conversation history - `/agent` (alias: `/a`) - List available agents or switch to specific agent -- `/firebase` - Check and configure Firebase credentials - `/transfer` (alias: `/t`) - Transfer session to agent CLI - `/resume` - Resume a previous session - `/install-mcp` (alias: `/mcp`) - Install Clix MCP Server for current agent @@ -380,94 +378,31 @@ Describe the problem: Events not appearing in Clix dashboard [Provides fix with exact file location] ``` -#### `/ios-setup` - iOS Setup, Capabilities, NSE & APNS Key Configuration +#### iOS Push Preparation in `/install` -**Category:** Autonomous Command +`/install` includes required iOS push setup tasks and blocks progress until each required task is complete. -**Aliases:** `/capabilities`, `/ios-capabilities` - -**What it does:** Configures iOS capabilities, Notification Service Extension (NSE), and optionally sets up APNS authentication key for Firebase push notifications. - -**Capabilities configured:** -- **Push Notifications** - Enables APNs communication (entitlement: `aps-environment`) -- **App Groups** - Enables data sharing between app and NSE (entitlement: `com.apple.security.application-groups`, format: `group.clix.{BUNDLE_ID}`) - -**Notification Service Extension (NSE) setup:** -- **Target name:** `{AppName}NotificationServiceExtension` (consistent across all steps) -- **NotificationService.swift:** Uses `ClixNotificationServiceExtension` base class with `register(projectId:)` call -- **Dependency setup:** CocoaPods (`pod 'Clix'` in extension target) or SPM (add Clix package to extension) -- **Build settings:** `ENABLE_USER_SCRIPT_SANDBOXING = No` for Xcode 15+ -- **React Native + Firebase:** Move "Embed Foundation Extensions" above "[RNFB] Core Configuration" - -**APNS Key Setup (Phase 3 - Optional):** -After iOS capabilities setup completes, prompts user to configure APNS authentication key: -- **APNS Key Creation:** Opens Apple Developer Portal to create/download .p8 key file -- **Key Validation:** Validates P8 file format, extracts Key ID from filename -- **Firebase Upload:** Opens Firebase Console and displays Key ID, Team ID for manual upload - -**Workflow (3 Phases):** -1. **Phase 1 - Direct Setup (Automatic):** - - Analyzes iOS project structure (finds .xcodeproj/.xcworkspace) - - Detects Bundle ID and checks current capabilities status - - Syncs capabilities with Apple Developer Portal (if credentials provided) - - Creates/modifies entitlements files for main app -2. **Phase 2 - Guided Setup (Static file generation + UI guide):** - - Auto-generates NSE files: - - `NotificationService.swift` with `ClixNotificationServiceExtension` - - `Info.plist` for extension - - Extension entitlements file - - Step-by-step Xcode configuration guide: - - Creating NSE target in Xcode - - Configuring build settings (`ENABLE_USER_SCRIPT_SANDBOXING` for Xcode 15+) - - Adding CocoaPods/SPM dependencies -3. **Phase 3 - Push Setup (Optional):** - - Prompts: "Set up APNS key for Firebase push notifications? [Y/n]" - - If yes, opens PushSetupWizard for APNS key creation and Firebase upload +**Required order for iOS projects:** +1. Firebase configuration files +2. APNS key setup in Firebase +3. Firebase Service Account registration +4. iOS entitlements +5. Notification Service Extension (NSE) **What is automated:** -- Creating/modifying entitlements files -- Reading project configuration -- Generating NSE source files (NotificationService.swift, Info.plist) -- P8 file format validation +- Firebase file detection and validation +- Service account JSON import and sender config registration +- Entitlements file updates +- NSE file generation and verification checks **What requires manual action (guided by UI):** -- Creating NSE target in Xcode (File > New > Target > Notification Service Extension) -- Adding capabilities in Xcode UI (Signing & Capabilities) -- Adding Clix SDK to extension target (Podfile or SPM) -- Configuring build settings for Xcode 15+ -- Enabling capabilities in Apple Developer Portal -- Registering App Group IDs -- Regenerating provisioning profiles -- Creating APNS key in Apple Developer Portal -- Uploading P8 file to Firebase Console +- Creating/confirming APNS key in Apple Developer +- Uploading APNS key to Firebase Console +- Final Xcode target/capability confirmation for NSE -**Example:** +**Entry point:** ``` -> /ios-setup -Analyzing iOS project... -✓ Found: MyApp.xcodeproj -✓ Bundle ID: com.example.myapp - -Creating entitlements files... -✓ Created MyApp.entitlements - -Creating Notification Service Extension files... -✓ Created MyAppNotificationServiceExtension/NotificationService.swift -✓ Created MyAppNotificationServiceExtension/Info.plist -✓ Created MyAppNotificationServiceExtension/MyAppNotificationServiceExtension.entitlements - -Step 1: Create Notification Service Extension Target in Xcode -[Enter to continue...] - -Step 2: Configure Build Settings -[Enter to continue...] - -Step 3: Add Clix SDK to Extension Target -[Enter to continue...] - -✓ Extension setup guide complete! - -Set up APNS key for Firebase push notifications? [Y/n] +> /install ``` ### Interactive Skills @@ -576,7 +511,6 @@ System Commands: /new - Start a new session /compact - Compress conversation history /agent - List or switch agents - /firebase - Check and configure Firebase credentials /transfer - Transfer to agent CLI /resume - Resume a previous session /install-mcp - Install Clix MCP Server @@ -623,39 +557,6 @@ Compressing conversation history... Reduced size by 68% ``` -#### `/firebase` - Firebase Configuration - -**Category:** System Command - -**What it does:** Opens an interactive wizard to check and configure Firebase credentials (google-services.json for Android, GoogleService-Info.plist for iOS). - -**Features:** -- Auto-detects Firebase credential files in project -- Validates JSON/plist structure against Firebase schema -- Reports issues (missing files, invalid format, wrong location) -- Provides recommendations with help URLs -- Supports React Native, Flutter, iOS, and Android projects - -**Detection locations:** -- Android: `app/google-services.json`, `android/app/google-services.json` -- iOS: `ios/GoogleService-Info.plist`, `ios/Runner/GoogleService-Info.plist` - -**Example:** -``` -> /firebase -Firebase Configuration (react-native) - -✓ Android (google-services.json): valid - Location: android/app/google-services.json - Project ID: my-project-12345 - -✗ iOS (GoogleService-Info.plist): not found - → Expected: ios/GoogleService-Info.plist - → Download from Firebase Console - -Press Enter to configure, Esc to skip -``` - #### `/agent` - Agent Management **Category:** System Command @@ -1275,8 +1176,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. **20 slash commands** - 4 autonomous commands + 5 interactive skills + 11 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 +4. **Slash commands** - Autonomous commands + interactive skills + system commands +5. **Autonomous vs Interactive** - Autonomous commands (`/install`, `/doctor`, `/debug`) 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 `.clix/sessions/`, provides command for native CLI @@ -1294,7 +1195,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, App Groups, and Notification Service Extension (NSE) +- "iOS capabilities" → Use `/install` to run required iOS push setup tasks (Firebase files, APNS, service account, entitlements, NSE) - "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/src/commands/skill/__tests__/preparation.test.ts b/src/commands/skill/__tests__/preparation.test.ts index b4162e4..22c70b0 100644 --- a/src/commands/skill/__tests__/preparation.test.ts +++ b/src/commands/skill/__tests__/preparation.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; import type { ProjectType } from '@/lib/config'; -import { checkFirebaseStatus, checkIosStatus } from '../preparation'; +import { checkApnsStatus, checkFirebaseStatus, checkIosStatus } from '../preparation'; // Mock the dependencies const mockFirebaseService = { @@ -32,6 +32,67 @@ const mockFirebaseService = { ), }; +const mockInternalApiClient = { + getProject: mock(() => + Promise.resolve({ + id: 'clix-project', + name: 'Project', + organization_id: 'org-1', + sender_configs: [{ channel_type: 'CHANNEL_TYPE_APP_PUSH' }], + }), + ), +}; + +interface MockIosProject { + projectPath: string; + workspacePath?: string; + bundleId: string; + appName: string; + targets: string[]; + entitlementsFiles: string[]; + teamId?: string; +} + +interface MockIosProjectAnalysisResult { + success: boolean; + project?: MockIosProject; + error?: string; +} + +interface MockEntitlementsData { + 'aps-environment'?: string; + 'com.apple.security.application-groups'?: string[]; + [key: string]: unknown; +} + +const mockIos = { + analyzeIosProject: mock<(cwd: string) => Promise>(() => + Promise.resolve({ + success: false, + error: 'No iOS project', + }), + ), + generateAppGroupId: mock<(bundleId: string) => string>( + (bundleId: string) => `group.clix.${bundleId}`, + ), + getIosProjectDir: mock<(projectPath: string) => string>(() => '/test/ios'), + hasClixConfiguration: mock< + ( + entitlements: MockEntitlementsData | null, + bundleId: string, + ) => { hasPush: boolean; hasAppGroup: boolean } + >(() => ({ hasPush: false, hasAppGroup: false })), + readEntitlements: mock<(entitlementsPath: string) => Promise>(() => + Promise.resolve(null), + ), + verifyExtensionFiles: mock< + (iosDir: string, appName: string) => { complete: boolean; missingFiles: string[] } + >(() => ({ + complete: false, + missingFiles: ['NotificationService.swift'], + })), +}; + mock.module('@/lib/services/firebase/firebase-service', () => ({ FirebaseService: class { detect = mockFirebaseService.detect; @@ -39,10 +100,54 @@ mock.module('@/lib/services/firebase/firebase-service', () => ({ }, })); +mock.module('@/lib/api', () => ({ + getInternalApiClient: () => mockInternalApiClient, +})); + +mock.module('@/lib/ios', () => ({ + analyzeIosProject: mockIos.analyzeIosProject, + generateAppGroupId: mockIos.generateAppGroupId, + getIosProjectDir: mockIos.getIosProjectDir, + hasClixConfiguration: mockIos.hasClixConfiguration, + readEntitlements: mockIos.readEntitlements, + verifyExtensionFiles: mockIos.verifyExtensionFiles, +})); + describe('preparation', () => { beforeEach(() => { mockFirebaseService.detect.mockClear(); mockFirebaseService.getStatus.mockClear(); + mockInternalApiClient.getProject.mockClear(); + mockInternalApiClient.getProject.mockImplementation(() => + Promise.resolve({ + id: 'clix-project', + name: 'Project', + organization_id: 'org-1', + sender_configs: [{ channel_type: 'CHANNEL_TYPE_APP_PUSH' }], + }), + ); + + mockIos.analyzeIosProject.mockClear(); + mockIos.generateAppGroupId.mockClear(); + mockIos.getIosProjectDir.mockClear(); + mockIos.hasClixConfiguration.mockClear(); + mockIos.readEntitlements.mockClear(); + mockIos.verifyExtensionFiles.mockClear(); + + mockIos.analyzeIosProject.mockImplementation(() => + Promise.resolve({ + success: false, + error: 'No iOS project', + }), + ); + mockIos.generateAppGroupId.mockImplementation((bundleId: string) => `group.clix.${bundleId}`); + mockIos.getIosProjectDir.mockImplementation(() => '/test/ios'); + mockIos.hasClixConfiguration.mockImplementation(() => ({ hasPush: false, hasAppGroup: false })); + mockIos.readEntitlements.mockImplementation(() => Promise.resolve(null)); + mockIos.verifyExtensionFiles.mockImplementation(() => ({ + complete: false, + missingFiles: ['NotificationService.swift'], + })); }); describe('checkIosStatus', () => { @@ -71,7 +176,7 @@ describe('preparation', () => { expect(status.needed).toBe(true); }); - test('should use existing setup status from config', async () => { + test('should ignore cached completion flags and fallback to file evidence', async () => { const projectType: ProjectType = { framework: 'native', target: 'ios' }; const setup = { ios: { @@ -89,11 +194,112 @@ describe('preparation', () => { expect(status.bundleId).toBe('com.test.app'); expect(status.teamId).toBe('ABC123'); expect(status.appGroupId).toBe('group.com.test.app'); + expect(status.entitlementsConfigured).toBe(false); + expect(status.nseConfigured).toBe(false); + }); + + test('should mark iOS setup complete only when required files are verified', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + + mockIos.analyzeIosProject.mockResolvedValueOnce({ + success: true, + project: { + projectPath: '/test/ios/MyApp.xcodeproj', + workspacePath: '/test/ios/MyApp.xcworkspace', + bundleId: 'com.test.app', + appName: 'MyApp', + targets: ['MyApp'], + entitlementsFiles: ['/test/ios/MyApp/MyApp.entitlements'], + teamId: 'TEAM123456', + }, + }); + mockIos.readEntitlements.mockResolvedValueOnce({ + 'aps-environment': 'development', + 'com.apple.security.application-groups': ['group.clix.com.test.app'], + }); + mockIos.hasClixConfiguration.mockReturnValueOnce({ hasPush: true, hasAppGroup: true }); + mockIos.verifyExtensionFiles.mockReturnValueOnce({ complete: true, missingFiles: [] }); + + const status = await checkIosStatus('/test', projectType); + + expect(status.needed).toBe(true); + expect(status.bundleId).toBe('com.test.app'); + expect(status.teamId).toBe('TEAM123456'); + expect(status.appGroupId).toBe('group.clix.com.test.app'); + expect(status.entitlementsConfigured).toBe(true); + expect(status.nseConfigured).toBe(true); + }); + + test('should continue scanning entitlements files when one file is malformed', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + + mockIos.analyzeIosProject.mockResolvedValueOnce({ + success: true, + project: { + projectPath: '/test/ios/MyApp.xcodeproj', + workspacePath: '/test/ios/MyApp.xcworkspace', + bundleId: 'com.test.app', + appName: 'MyApp', + targets: ['MyApp'], + entitlementsFiles: [ + '/test/ios/MyApp/bad.entitlements', + '/test/ios/MyApp/MyApp.entitlements', + ], + teamId: 'TEAM123456', + }, + }); + mockIos.readEntitlements + .mockRejectedValueOnce(new Error('invalid plist')) + .mockResolvedValueOnce({ + 'aps-environment': 'development', + 'com.apple.security.application-groups': ['group.clix.com.test.app'], + }); + mockIos.hasClixConfiguration.mockReturnValueOnce({ hasPush: true, hasAppGroup: true }); + mockIos.verifyExtensionFiles.mockReturnValueOnce({ complete: true, missingFiles: [] }); + + const status = await checkIosStatus('/test', projectType); + expect(status.entitlementsConfigured).toBe(true); expect(status.nseConfigured).toBe(true); }); }); + describe('checkApnsStatus', () => { + test('should return needed=false for Android-only projects', async () => { + const projectType: ProjectType = { framework: 'native', target: 'android' }; + const status = await checkApnsStatus('/test', projectType); + + expect(status.needed).toBe(false); + expect(status.registeredWithFirebase).toBe(true); + }); + + test('should return needed=true and not configured for iOS projects without setup', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + const status = await checkApnsStatus('/test', projectType); + + expect(status.needed).toBe(true); + expect(status.registeredWithFirebase).toBe(false); + }); + + test('should use existing APNS setup status from config', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + const setup = { + apns: { + keyId: 'ABC1234567', + teamId: 'TEAMID1234', + registeredWithFirebase: true, + }, + }; + + const status = await checkApnsStatus('/test', projectType, setup); + + expect(status.needed).toBe(true); + expect(status.keyId).toBe('ABC1234567'); + expect(status.teamId).toBe('TEAMID1234'); + expect(status.registeredWithFirebase).toBe(true); + }); + }); + describe('checkFirebaseStatus', () => { test('should return needed=false for unknown target', async () => { const projectType: ProjectType = { framework: 'unknown', target: 'unknown' }; @@ -101,6 +307,7 @@ describe('preparation', () => { expect(status.needed).toBe(false); expect(status.configured).toBe(true); + expect(status.senderConfigConfigured).toBe(true); }); test('should always detect files even if setup config exists', async () => { @@ -113,14 +320,16 @@ describe('preparation', () => { }, }; - const status = await checkFirebaseStatus('/test', projectType, setup); + const status = await checkFirebaseStatus('/test', projectType, setup, 'clix-project'); // Should always run file detection regardless of cached setup expect(mockFirebaseService.detect).toHaveBeenCalled(); expect(mockFirebaseService.getStatus).toHaveBeenCalled(); + expect(mockInternalApiClient.getProject).toHaveBeenCalledWith('clix-project'); expect(status.configured).toBe(true); // Project ID from detected files takes precedence over cached config expect(status.projectId).toBe('test-project'); + expect(status.senderConfigConfigured).toBe(true); }); test('should fallback to cached projectId when files have no project ID', async () => { @@ -142,7 +351,7 @@ describe('preparation', () => { projectPath: '/test', }); - const status = await checkFirebaseStatus('/test', projectType, setup); + const status = await checkFirebaseStatus('/test', projectType, setup, 'clix-project'); expect(status.projectId).toBe('my-project'); }); @@ -150,11 +359,12 @@ describe('preparation', () => { test('should detect Firebase config when not in setup', async () => { const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; - const status = await checkFirebaseStatus('/test', projectType); + const status = await checkFirebaseStatus('/test', projectType, undefined, 'clix-project'); expect(status.needed).toBe(true); expect(mockFirebaseService.detect).toHaveBeenCalled(); expect(mockFirebaseService.getStatus).toHaveBeenCalled(); + expect(mockInternalApiClient.getProject).toHaveBeenCalledWith('clix-project'); }); test('should check only Android for Android-only projects', async () => { @@ -169,7 +379,7 @@ describe('preparation', () => { warningCount: 0, }); - const status = await checkFirebaseStatus('/test', projectType); + const status = await checkFirebaseStatus('/test', projectType, undefined, 'clix-project'); expect(status.configured).toBe(true); expect(status.androidConfigured).toBe(true); @@ -189,12 +399,39 @@ describe('preparation', () => { warningCount: 0, }); - const status = await checkFirebaseStatus('/test', projectType); + const status = await checkFirebaseStatus('/test', projectType, undefined, 'clix-project'); expect(status.configured).toBe(true); // Android not needed, so configured is true expect(status.androidConfigured).toBe(true); expect(status.iosConfigured).toBe(true); }); + + test('should return configured=false when sender config is missing', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + mockInternalApiClient.getProject.mockResolvedValueOnce({ + id: 'clix-project', + name: 'Project', + organization_id: 'org-1', + sender_configs: [], + }); + + const status = await checkFirebaseStatus('/test', projectType, undefined, 'clix-project'); + + expect(status.iosConfigured).toBe(true); + expect(status.senderConfigConfigured).toBe(false); + expect(status.configured).toBe(false); + }); + + test('should return configured=false when sender config check fails', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + mockInternalApiClient.getProject.mockRejectedValueOnce(new Error('API failure')); + + const status = await checkFirebaseStatus('/test', projectType, undefined, 'clix-project'); + + expect(status.iosConfigured).toBe(true); + expect(status.senderConfigConfigured).toBe(false); + expect(status.configured).toBe(false); + }); }); }); diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts index 615d3e5..44a228e 100644 --- a/src/commands/skill/preparation.ts +++ b/src/commands/skill/preparation.ts @@ -7,12 +7,21 @@ * @module commands/skill/preparation */ +import { getInternalApiClient } from '@/lib/api'; import { getProjectConfigManager, type ProjectConfig, type ProjectType, type SetupStatus, } from '@/lib/config'; +import { + analyzeIosProject, + generateAppGroupId, + getIosProjectDir, + hasClixConfiguration, + readEntitlements, + verifyExtensionFiles, +} from '@/lib/ios'; import { FirebaseService } from '@/lib/services/firebase/firebase-service'; import { detectProjectType } from '@/lib/services/project-detector'; @@ -26,6 +35,8 @@ export interface FirebaseStatus { androidConfigured: boolean; /** Whether iOS config (GoogleService-Info.plist) exists and is valid */ iosConfigured: boolean; + /** Whether app push sender config is registered in Clix project */ + senderConfigConfigured: boolean; /** Firebase project ID if detected */ projectId?: string; /** Whether Firebase setup is needed based on project type */ @@ -50,6 +61,20 @@ export interface IosStatus { nseConfigured: boolean; } +/** + * Status of APNS key setup for Firebase iOS push. + */ +export interface ApnsStatus { + /** Whether APNS setup is needed based on project type */ + needed: boolean; + /** APNS Key ID if configured */ + keyId?: string; + /** Apple Team ID if configured */ + teamId?: string; + /** Whether APNS key is registered with Firebase */ + registeredWithFirebase: boolean; +} + /** * Context passed to the install skill after preparation. */ @@ -64,6 +89,8 @@ export interface PreparationContext { firebase: FirebaseStatus; /** iOS configuration status */ ios: IosStatus; + /** APNS configuration status */ + apns: ApnsStatus; /** Whether all required preparations are complete */ ready: boolean; /** List of missing preparations */ @@ -143,18 +170,42 @@ export async function ensureProjectType( return { config: updatedConfig, projectType }; } +/** + * Check whether app push sender config is registered for the Clix project. + * + * API/network errors are treated as not configured to avoid false positives. + */ +async function hasAppPushSenderConfig(clixProjectId?: string): Promise { + if (!clixProjectId) { + return false; + } + + try { + const apiClient = getInternalApiClient(); + const project = await apiClient.getProject(clixProjectId); + return ( + project.sender_configs?.some((config) => config.channel_type === 'CHANNEL_TYPE_APP_PUSH') ?? + false + ); + } catch { + return false; + } +} + /** * Check Firebase configuration status. * * @param projectPath - Path to the project root * @param projectType - Detected project type * @param setup - Current setup status from config + * @param clixProjectId - Clix project ID for sender config verification * @returns Firebase status */ export async function checkFirebaseStatus( projectPath: string, projectType: ProjectType, setup?: SetupStatus, + clixProjectId?: string, ): Promise { // Determine if Firebase is needed based on project type const needed = projectType.target !== 'unknown'; @@ -164,6 +215,7 @@ export async function checkFirebaseStatus( configured: true, androidConfigured: true, iosConfigured: true, + senderConfigConfigured: true, needed: false, }; } @@ -179,6 +231,10 @@ export async function checkFirebaseStatus( const androidConfigured = !needsAndroid || status.androidConfigured; const iosConfigured = !needsIos || status.iosConfigured; + const credentialFilesConfigured = androidConfigured && iosConfigured; + const senderConfigConfigured = credentialFilesConfigured + ? await hasAppPushSenderConfig(clixProjectId) + : false; // Extract project ID from detected files, fallback to cached setup let projectId: string | undefined; @@ -191,14 +247,83 @@ export async function checkFirebaseStatus( } return { - configured: androidConfigured && iosConfigured, + configured: credentialFilesConfigured && senderConfigConfigured, androidConfigured, iosConfigured, + senderConfigConfigured, projectId, needed: true, }; } +interface IosFileStatus { + bundleId?: string; + teamId?: string; + appGroupId?: string; + entitlementsConfigured: boolean; + nseConfigured: boolean; +} + +/** + * Detect iOS setup status from actual project files. + */ +async function detectIosStatusFromFiles( + projectPath: string, + setup?: SetupStatus['ios'], +): Promise { + const analysis = await analyzeIosProject(projectPath).catch(() => ({ + success: false as const, + })); + + if (!analysis.success || !analysis.project) { + return { + bundleId: setup?.bundleId, + teamId: setup?.teamId, + appGroupId: setup?.appGroupId, + entitlementsConfigured: false, + nseConfigured: false, + }; + } + + const project = analysis.project; + const expectedAppGroupId = generateAppGroupId(project.bundleId); + let hasEntitlements = false; + let detectedAppGroupId: string | undefined; + + for (const entitlementsPath of project.entitlementsFiles) { + try { + const entitlements = await readEntitlements(entitlementsPath); + const clixConfig = hasClixConfiguration(entitlements, project.bundleId); + if (!clixConfig.hasPush || !clixConfig.hasAppGroup) { + continue; + } + + hasEntitlements = true; + const appGroups = entitlements?.['com.apple.security.application-groups']; + if (Array.isArray(appGroups)) { + detectedAppGroupId = + appGroups.find((group) => group === expectedAppGroupId) ?? + appGroups[0] ?? + detectedAppGroupId; + } + break; + } catch { + // Ignore malformed entitlements files and continue scanning. + } + } + + const iosDir = getIosProjectDir(projectPath); + const hasNseFiles = iosDir ? verifyExtensionFiles(iosDir, project.appName).complete : false; + + return { + bundleId: project.bundleId, + teamId: project.teamId, + appGroupId: detectedAppGroupId ?? setup?.appGroupId, + entitlementsConfigured: hasEntitlements, + nseConfigured: hasNseFiles, + }; +} + /** * Check iOS configuration status. * @@ -208,7 +333,7 @@ export async function checkFirebaseStatus( * @returns iOS status */ export async function checkIosStatus( - _projectPath: string, + projectPath: string, projectType: ProjectType, setup?: SetupStatus, ): Promise { @@ -222,24 +347,44 @@ export async function checkIosStatus( nseConfigured: true, }; } + const fileStatus = await detectIosStatusFromFiles(projectPath, setup?.ios); + return { + needed: true, + ...fileStatus, + }; +} + +/** + * Check APNS setup status. + * + * APNS key setup is required only for iOS targets. + */ +export async function checkApnsStatus( + _projectPath: string, + projectType: ProjectType, + setup?: SetupStatus, +): Promise { + const needed = projectType.target === 'ios' || projectType.target === 'ios-android'; + + if (!needed) { + return { + needed: false, + registeredWithFirebase: true, + }; + } - // Check setup status from config - if (setup?.ios) { + if (setup?.apns) { return { needed: true, - bundleId: setup.ios.bundleId, - teamId: setup.ios.teamId, - appGroupId: setup.ios.appGroupId, - entitlementsConfigured: setup.ios.entitlementsConfigured, - nseConfigured: setup.ios.nseConfigured, + keyId: setup.apns.keyId, + teamId: setup.apns.teamId, + registeredWithFirebase: setup.apns.registeredWithFirebase, }; } - // No iOS setup recorded return { needed: true, - entitlementsConfigured: false, - nseConfigured: false, + registeredWithFirebase: false, }; } @@ -265,14 +410,26 @@ export async function gatherPreparationContext( const { config: updatedConfig, projectType } = await ensureProjectType(config, projectPath); // Step 3: Check Firebase status - const firebase = await checkFirebaseStatus(projectPath, projectType, updatedConfig.setup); + const firebase = await checkFirebaseStatus( + projectPath, + projectType, + updatedConfig.setup, + updatedConfig.project.id, + ); // Step 4: Check iOS status const ios = await checkIosStatus(projectPath, projectType, updatedConfig.setup); - // Step 5: Determine what's missing + // Step 5: Check APNS status + const apns = await checkApnsStatus(projectPath, projectType, updatedConfig.setup); + + // Step 6: Determine what's missing const missing: string[] = []; + if (apns.needed && firebase.iosConfigured && !apns.registeredWithFirebase) { + missing.push('APNS Key for Firebase'); + } + if (firebase.needed && !firebase.configured) { if (!firebase.androidConfigured) { missing.push('Firebase Android config (google-services.json)'); @@ -280,6 +437,13 @@ export async function gatherPreparationContext( if (!firebase.iosConfigured) { missing.push('Firebase iOS config (GoogleService-Info.plist)'); } + const senderConfigPrerequisitesMet = + firebase.androidConfigured && + firebase.iosConfigured && + (!apns.needed || apns.registeredWithFirebase); + if (senderConfigPrerequisitesMet && !firebase.senderConfigConfigured) { + missing.push('Firebase Service Account'); + } } if (ios.needed) { @@ -299,6 +463,7 @@ export async function gatherPreparationContext( projectType, firebase, ios, + apns, ready, missing, }; @@ -315,6 +480,7 @@ export async function saveSetupStatus( projectPath: string, firebase: FirebaseStatus, ios: IosStatus, + apns?: ApnsStatus, ): Promise { const manager = getProjectConfigManager(projectPath); const now = new Date().toISOString(); @@ -341,5 +507,14 @@ export async function saveSetupStatus( }; } + if (apns?.needed) { + setupUpdate.apns = { + keyId: apns.keyId, + teamId: apns.teamId, + registeredWithFirebase: apns.registeredWithFirebase, + completedAt: apns.registeredWithFirebase ? now : undefined, + }; + } + await manager.updateSetup(setupUpdate); } diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index 2d4eea7..73f9fd9 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -290,6 +290,7 @@ describe('preparationContext in install skill', () => { configured: true, androidConfigured: true, iosConfigured: true, + senderConfigConfigured: true, projectId: 'my-firebase-project', }, ios: { @@ -300,6 +301,12 @@ describe('preparationContext in install skill', () => { entitlementsConfigured: true, nseConfigured: false, }, + apns: { + needed: true, + keyId: 'ABC1234567', + teamId: 'TEAM123', + registeredWithFirebase: true, + }, missing: ['Notification Service Extension'], ready: false, }; @@ -354,6 +361,7 @@ describe('preparationContext in install skill', () => { configured: true, androidConfigured: true, iosConfigured: true, + senderConfigConfigured: true, projectId: 'android-project', }, ios: { @@ -364,6 +372,10 @@ describe('preparationContext in install skill', () => { entitlementsConfigured: true, nseConfigured: true, }, + apns: { + needed: false, + registeredWithFirebase: true, + }, missing: [], ready: true, }; @@ -399,6 +411,7 @@ describe('preparationContext in install skill', () => { configured: true, androidConfigured: true, iosConfigured: true, + senderConfigConfigured: true, projectId: undefined, }, ios: { @@ -409,6 +422,10 @@ describe('preparationContext in install skill', () => { entitlementsConfigured: true, nseConfigured: true, }, + apns: { + needed: false, + registeredWithFirebase: true, + }, missing: [], ready: true, }; diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts index da79f0f..b9691f8 100644 --- a/src/lib/commands/skills.ts +++ b/src/lib/commands/skills.ts @@ -81,7 +81,6 @@ function generateSkillCommands(): Command[] { ), ); commands.push(createLocalSkillCommand('doctor', 'Check SDK integration status', 'doctor')); - // NOTE: ios-setup is now a LocalJSXCommand in registry.ts, not a skill return commands; } diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index 8d3286f..cf615b8 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -1224,7 +1224,7 @@ For push notifications to work, Firebase must be properly configured: - Verify JSON/plist structure is valid - Confirm project IDs match between platforms (for cross-platform apps) -Use \`/firebase\` command in interactive mode to check and configure Firebase credentials. +Use \`/install\` in interactive mode to run required Firebase setup tasks. ## Automation Rules @@ -1282,7 +1282,7 @@ For rich push notifications (images, buttons), create a Notification Service Ext 4. Add App Groups capability to both main app and extension (same group ID: \`group.clix.{BUNDLE_ID}\`) 5. For Xcode 15+: Set \`ENABLE_USER_SCRIPT_SANDBOXING\` to "No" in extension's Build Settings -For detailed setup, run \`clix ios-setup\` or \`/ios-setup\` in interactive mode. +For detailed setup, run \`clix install\` or \`/install\` in interactive mode. ## Output Format @@ -1392,7 +1392,7 @@ Analyze the project and output a diagnostic JSON report: Output the JSON diagnostic, then provide a brief summary with actionable recommendations. -Use \`/firebase\` command to interactively check and configure Firebase credentials. +Use \`/install\` to run interactive setup for missing Firebase credentials. `, }; diff --git a/src/lib/push/types.ts b/src/lib/push/types.ts index a90230a..4270933 100644 --- a/src/lib/push/types.ts +++ b/src/lib/push/types.ts @@ -18,7 +18,7 @@ export interface ApnsPushKey { } /** - * Push setup wizard context. + * Push setup task context. */ export interface PushSetupContext { // Project info @@ -30,9 +30,9 @@ export interface PushSetupContext { } /** - * Push setup wizard phases. + * Push setup task phases. */ -export type PushSetupPhase = +export type PushSetupTaskPhase = | 'detecting' // Analyzing project | 'status' // Showing current status | 'key_source' // Asking if user has existing key @@ -47,9 +47,9 @@ export type PushSetupPhase = | 'error'; // Error state /** - * Push setup result. + * Push setup task result. */ -export interface PushSetupResult { +export interface PushSetupTaskResult { success: boolean; message: string; context?: PushSetupContext; diff --git a/src/lib/services/firebase/types.ts b/src/lib/services/firebase/types.ts index cb57885..c5afc60 100644 --- a/src/lib/services/firebase/types.ts +++ b/src/lib/services/firebase/types.ts @@ -221,25 +221,7 @@ export const FIREBASE_HELP_URLS = { } as const; /** - * Credential action types for the wizard menu. - */ -export type CredentialAction = - | { type: 'redetect' } - | { type: 'redetect_platform'; platform: 'android' | 'ios' } - | { type: 'validate'; platform: 'android' | 'ios' } - | { type: 'help'; topic: keyof typeof FIREBASE_HELP_URLS } - | { type: 'download' } - | { type: 'skip' } - | { type: 'done' } - | { type: 'setup_service_account' }; - -/** - * Wizard phase states. - */ -export type WizardPhase = 'detecting' | 'status' | 'menu' | 'validating' | 'error' | 'complete'; - -/** - * Result from Firebase setup wizard. + * Result from Firebase setup flow. */ export interface FirebaseSetupResult { /** Whether setup was completed */ diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 2c23d1f..5fd37a6 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -62,7 +62,6 @@ const LOCAL_SKILLS: SkillInfo[] = [ description: 'Interactive debugging assistant', isLocal: true, }, - // NOTE: ios-setup is now a LocalJSXCommand in registry.ts, not a skill ]; /** @@ -259,6 +258,19 @@ function buildPreparationSection(context: PreparationContext): string { lines.push( `- iOS (GoogleService-Info.plist): ${context.firebase.iosConfigured ? '✓ configured' : '✗ missing'}`, ); + lines.push( + `- Sender Config (App Push): ${context.firebase.senderConfigConfigured ? '✓ configured' : '✗ missing'}`, + ); + lines.push(''); + } + + if (context.apns.needed) { + lines.push('### APNS'); + lines.push(`- Key ID: ${context.apns.keyId || 'not configured'}`); + lines.push(`- Team ID: ${context.apns.teamId || 'not configured'}`); + lines.push( + `- Registered with Firebase: ${context.apns.registeredWithFirebase ? '✓ configured' : '✗ missing'}`, + ); lines.push(''); } diff --git a/src/lib/skills/doctor/SKILL.md b/src/lib/skills/doctor/SKILL.md index 85108d7..58926d4 100644 --- a/src/lib/skills/doctor/SKILL.md +++ b/src/lib/skills/doctor/SKILL.md @@ -98,4 +98,4 @@ Analyze the project and output a diagnostic JSON report: Output the JSON diagnostic, then provide a brief summary with actionable recommendations. -Use `/firebase` command to interactively check and configure Firebase credentials. +Use `/install` to run interactive setup for missing Firebase credentials. diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index 0d718ad..11c4a00 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -137,7 +137,7 @@ For push notifications to work, Firebase must be properly configured: - Verify JSON/plist structure is valid - Confirm project IDs match between platforms (for cross-platform apps) -Use `/firebase` command in interactive mode to check and configure Firebase credentials. +Use `/install` in interactive mode to run required Firebase setup tasks. ## Automation Rules @@ -195,7 +195,7 @@ For rich push notifications (images, buttons), create a Notification Service Ext 4. Add App Groups capability to both main app and extension (same group ID: `group.clix.{BUNDLE_ID}`) 5. For Xcode 15+: Set `ENABLE_USER_SCRIPT_SANDBOXING` to "No" in extension's Build Settings -For detailed setup, run `clix ios-setup` or `/ios-setup` in interactive mode. +For detailed setup, run `clix install` or `/install` in interactive mode. ## Output Format diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index ac37888..906cf5a 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -6,9 +6,7 @@ import { getProjectConfigManager, type ProjectConfig } from '../../lib/config'; import type { InstallationMethod, UpdateCheckResult } from '../../lib/services/update-service'; import { AgentSelector } from '../components/AgentSelector'; import { DebugPrompt } from '../components/DebugPrompt'; -import { FirebaseWizard } from '../components/FirebaseWizard'; import { InstallPreparationUI } from '../components/InstallPreparationUI'; -import { IosSetupFlow } from '../components/IosSetupFlow'; import { MCPInstallSelector } from '../components/MCPInstallSelector'; import { SessionSelector } from '../components/SessionSelector'; import { TransferSelector } from '../components/TransferSelector'; @@ -194,9 +192,11 @@ const ChatAppInner: React.FC /> )} - - - + {!isOverlayActive && ( + + + + )} {/* Overlays */} {overlays.activeOverlay === 'agent' && ( @@ -238,21 +238,6 @@ const ChatAppInner: React.FC {overlays.activeOverlay === 'debug' && ( )} - {overlays.activeOverlay === 'firebase' && ( - - )} - {overlays.activeOverlay === 'ios-setup' && ( - { - overlays.handleIosSetupComplete(result.message); - }} - /> - )} {overlays.activeOverlay === 'install-preparation' && ( setActiveOverlay('debug'), []); - const showFirebaseWizard = useCallback(() => setActiveOverlay('firebase'), []); - const showIosSetupOverlay = useCallback(() => setActiveOverlay('ios-setup'), []); const showInstallPreparation = useCallback(() => setActiveOverlay('install-preparation'), []); const showLoginOverlay = useCallback(() => setActiveOverlay('login'), []); const showLogoutOverlay = useCallback(() => setActiveOverlay('logout'), []); @@ -242,33 +238,6 @@ export function useOverlays(options: UseOverlaysOptions) { [executeDebugSession], ); - // Firebase wizard handlers - const handleFirebaseComplete = useCallback( - (result: { completed?: boolean; skipped?: boolean }) => { - setActiveOverlay(null); - if (result.skipped) { - addSystemMessage('Firebase setup skipped'); - } else if (result.completed) { - addSystemMessage('Firebase configuration complete'); - } - }, - [addSystemMessage], - ); - - const handleFirebaseCancel = useCallback(() => { - setActiveOverlay(null); - addSystemMessage('Firebase setup cancelled'); - }, [addSystemMessage]); - - // iOS setup handlers - const handleIosSetupComplete = useCallback( - (message: string) => { - setActiveOverlay(null); - addSystemMessage(message); - }, - [addSystemMessage], - ); - // Install preparation handlers const handleInstallPreparationComplete = useCallback( (context: PreparationContext) => { @@ -345,15 +314,6 @@ export function useOverlays(options: UseOverlaysOptions) { showDebugPrompt, handleDebugPromptSubmit, - // Firebase wizard - showFirebaseWizard, - handleFirebaseComplete, - handleFirebaseCancel, - - // iOS setup - showIosSetupOverlay, - handleIosSetupComplete, - // Install preparation showInstallPreparation, handleInstallPreparationComplete, diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx new file mode 100644 index 0000000..fe690c7 --- /dev/null +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -0,0 +1,660 @@ +import { Box, Text, useInput } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import TextInput from 'ink-text-input'; +import type React from 'react'; +import { useCallback, useState } from 'react'; +import { + type AndroidApp, + type FirebaseDetectionResult, + type FirebaseProject, + type GcpProject, + type IosApp, + platformNeedsAndroid, + platformNeedsIos, +} from '@/lib/services/firebase'; +import { OAUTH_CALLBACK_CONFIG } from '@/lib/utils/oauth'; +import { useCancelInput } from '@/ui/hooks'; +import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; + +export interface NoAppsContext { + noAndroidApps: boolean; + noIosApps: boolean; + needsAndroid: boolean; + needsIos: boolean; +} + +export function platformNeedsAndroidWithUnknown( + platform: FirebaseDetectionResult['platform'], +): boolean { + return platformNeedsAndroid(platform) || platform === 'unknown'; +} + +export function platformNeedsIosWithUnknown( + platform: FirebaseDetectionResult['platform'], +): boolean { + return platformNeedsIos(platform) || platform === 'unknown'; +} + +export function hasValidFirebaseConfigFiles(result: FirebaseDetectionResult): boolean { + const needsAndroid = platformNeedsAndroidWithUnknown(result.platform); + const needsIos = platformNeedsIosWithUnknown(result.platform); + const hasAndroidConfig = !needsAndroid || Boolean(result.android?.valid); + const hasIosConfig = !needsIos || Boolean(result.ios?.valid); + return hasAndroidConfig && hasIosConfig; +} + +function DetectingPhase(): React.ReactElement { + return ( + + + Firebase Configuration Files + + + + + + Detecting Firebase configuration files... + + + ); +} + +function StatusPhase({ + result, + onContinue, + onCancel, +}: { + result: FirebaseDetectionResult; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onContinue(); + } + }); + + useCancelInput(onCancel); + + return ( + + + + Press Enter to download required files, Esc/Ctrl+C to cancel + + + ); +} + +function AuthenticatingPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { + useCancelInput(onCancel); + + return ( + + + Firebase Authentication + + + + + + Opening browser for Google authentication... + + + Complete authentication in your browser. + + + Press Esc/Ctrl+C to cancel + + + ); +} + +function ProjectSelectorPhase({ + projects, + onSelect, + onCancel, +}: { + projects: FirebaseProject[]; + onSelect: (project: FirebaseProject) => void; + onCancel: () => void; +}): React.ReactElement { + const items = projects.map((project) => ({ + label: project.displayName || project.projectId, + value: project, + })); + + useCancelInput(onCancel); + + return ( + + + Select Firebase Project + + onSelect(item.value)} /> + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +function AppSelectorPhase({ + apps, + platform, + onSelect, + onCancel, +}: { + apps: (AndroidApp | IosApp)[]; + platform: 'android' | 'ios'; + onSelect: (app: AndroidApp | IosApp) => void; + onCancel: () => void; +}): React.ReactElement { + const items = apps.map((app) => ({ + label: + app.displayName || + (platform === 'android' ? (app as AndroidApp).packageName : (app as IosApp).bundleId), + value: app, + })); + + useCancelInput(onCancel); + + return ( + + + {platform === 'android' ? 'Select Android App' : 'Select iOS App'} + + onSelect(item.value)} /> + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +function DownloadingPhase({ + platform, +}: { + platform: 'android' | 'ios' | 'both'; +}): React.ReactElement { + const message = + platform === 'both' + ? 'Downloading config files...' + : platform === 'android' + ? 'Downloading google-services.json...' + : 'Downloading GoogleService-Info.plist...'; + + return ( + + + Firebase Download + + + + + + {message} + + + ); +} + +function NoAppsFoundPhase({ + context, + onCreateAndroid, + onCreateIos, + onCancel, +}: { + context: NoAppsContext; + onCreateAndroid: () => void; + onCreateIos: () => void; + onCancel: () => void; +}): React.ReactElement { + const items: Array<{ label: string; value: string }> = []; + + if (context.noAndroidApps && context.needsAndroid) { + items.push({ + label: '➕ Create Android app', + value: 'create_android', + }); + } + + if (context.noIosApps && context.needsIos) { + items.push({ + label: '➕ Create iOS app', + value: 'create_ios', + }); + } + + items.push({ + label: '← Back', + value: 'cancel', + }); + + useCancelInput(onCancel); + + const handleSelect = (item: { value: string }) => { + switch (item.value) { + case 'create_android': + onCreateAndroid(); + break; + case 'create_ios': + onCreateIos(); + break; + default: + onCancel(); + break; + } + }; + + return ( + + + + No Apps Found + + + + No apps are registered in this Firebase project. + + + Would you like to create a new app? + + + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +function CreateAppInputPhase({ + platform, + onSubmit, + onCancel, +}: { + platform: 'android' | 'ios'; + onSubmit: (identifier: string, displayName?: string) => void; + onCancel: () => void; +}): React.ReactElement { + const [identifier, setIdentifier] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [stage, setStage] = useState<'identifier' | 'display_name'>('identifier'); + + useCancelInput(onCancel); + + const handleIdentifierSubmit = useCallback(() => { + if (identifier.trim()) { + setStage('display_name'); + } + }, [identifier]); + + const handleDisplayNameSubmit = useCallback(() => { + onSubmit(identifier.trim(), displayName.trim() || undefined); + }, [displayName, identifier, onSubmit]); + + const isAndroid = platform === 'android'; + const identifierLabel = isAndroid ? 'Package Name' : 'Bundle ID'; + + return ( + + + {isAndroid ? 'Create Android App' : 'Create iOS App'} + + + {stage === 'identifier' ? ( + <> + + {identifierLabel}: + (e.g., com.example.app) + + + {'>'} + + + + Enter to continue · Esc/Ctrl+C cancel + + + ) : ( + <> + + + {identifierLabel}: {identifier} + + + + Display Name: + (optional) + + + {'>'} + + + + Enter to create · Esc/Ctrl+C cancel + + + )} + + ); +} + +function CreatingAppPhase({ platform }: { platform: 'android' | 'ios' }): React.ReactElement { + return ( + + + Firebase App Creation + + + + + + {platform === 'android' ? 'Creating Android app...' : 'Creating iOS app...'} + + + This may take a few seconds... + + + ); +} + +function NoProjectsPhase({ + onOpenConsole, + onSelectGcp, + onCancel, +}: { + onOpenConsole: () => void; + onSelectGcp: () => void; + onCancel: () => void; +}): React.ReactElement { + const items = [ + { label: '🌐 Open Firebase Console', value: 'console' }, + { label: '📦 Add Firebase to existing GCP project', value: 'gcp' }, + { label: '← Back', value: 'cancel' }, + ]; + + useCancelInput(onCancel); + + const handleSelect = (item: { value: string }) => { + switch (item.value) { + case 'console': + onOpenConsole(); + break; + case 'gcp': + onSelectGcp(); + break; + default: + onCancel(); + break; + } + }; + + return ( + + + + No Firebase Projects Found + + + + No Firebase projects are associated with this account. + + + Create a project or add Firebase to an existing GCP project: + + + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +function GcpProjectSelectorPhase({ + projects, + onSelect, + onCancel, +}: { + projects: GcpProject[]; + onSelect: (project: GcpProject) => void; + onCancel: () => void; +}): React.ReactElement { + const items = projects.map((project) => ({ + label: project.name || project.projectId, + value: project, + })); + + useCancelInput(onCancel); + + return ( + + + Select GCP Project + + + Select a project to add Firebase: + + onSelect(item.value)} /> + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +function AddingFirebasePhase({ projectId }: { projectId: string }): React.ReactElement { + return ( + + + Adding Firebase + + + + + + Adding Firebase to {projectId}... + + + This may take a moment... + + + ); +} + +function isScopeInsufficientError(error: string): boolean { + return ( + error.includes('ACCESS_TOKEN_SCOPE_INSUFFICIENT') || + error.includes('insufficient authentication scopes') || + (error.includes('403') && error.includes('PERMISSION_DENIED')) + ); +} + +function getErrorHint(error: string): string | null { + if (isScopeInsufficientError(error)) { + return `Your OAuth token has insufficient permissions. +This usually happens when the required permissions have changed. +Press Enter to re-authenticate with updated permissions.`; + } + + if (error.includes('invalid_client')) { + return 'The OAuth client ID is invalid. Check your CLIX_GOOGLE_CLIENT_ID setting.'; + } + + if (error.includes('redirect_uri_mismatch')) { + return `OAuth redirect URI mismatch. Add this callback URL: ${OAUTH_CALLBACK_CONFIG.getCallbackUrlIp()}`; + } + + if (error.includes('invalid_request')) { + return 'OAuth request failed. Check .clix/debug.log for details.'; + } + + if (error.includes('API has not been used in project') || error.includes('it is disabled')) { + return 'A required Google Cloud API is disabled. Enable the API in Google Cloud Console and retry.'; + } + + return null; +} + +export { + AddingFirebasePhase as FirebaseConfigAddingFirebaseTask, + AppSelectorPhase as FirebaseConfigAppSelectorTask, + AuthenticatingPhase as FirebaseConfigAuthenticatingTask, + CreateAppInputPhase as FirebaseConfigCreateAppInputTask, + CreatingAppPhase as FirebaseConfigCreatingAppTask, + DetectingPhase as FirebaseConfigDetectingTask, + DownloadingPhase as FirebaseConfigDownloadingTask, + ErrorPhase as FirebaseConfigErrorTask, + GcpProjectSelectorPhase as FirebaseConfigGcpProjectSelectorTask, + NoAppsFoundPhase as FirebaseConfigNoAppsFoundTask, + NoProjectsPhase as FirebaseConfigNoProjectsTask, + ProjectSelectorPhase as FirebaseConfigProjectSelectorTask, + StatusPhase as FirebaseConfigStatusTask, +}; + +function ErrorPhase({ + error, + onRetry, + onCancel, +}: { + error: string; + onRetry: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onRetry(); + } + }); + + useCancelInput(onCancel); + + const hint = getErrorHint(error); + + return ( + + + + Firebase Configuration Error + + + + ✗ {error} + + {hint ? ( + + {hint} + + ) : ( + + See .clix/debug.log for details. + + )} + + Press Enter to retry, Esc/Ctrl+C to cancel + + + ); +} diff --git a/src/ui/components/FirebaseServiceAccountSetup.tsx b/src/ui/components/FirebaseServiceAccountSetup.tsx new file mode 100644 index 0000000..fafae39 --- /dev/null +++ b/src/ui/components/FirebaseServiceAccountSetup.tsx @@ -0,0 +1,433 @@ +import { Box, Text, useInput } from 'ink'; +import Spinner from 'ink-spinner'; +import TextInput from 'ink-text-input'; +import type React from 'react'; +import { useCallback, useState } from 'react'; +import { + type FirebaseDetectionResult, + parseServiceAccountJson, + platformNeedsAndroid, + platformNeedsIos, + type ServiceAccountJson, + type ServiceAccountValidationResult, +} from '@/lib/services/firebase'; +import { useCancelInput } from '@/ui/hooks'; +import { readTextFileFromInputPath } from './file-input-utils'; + +export function platformNeedsAndroidWithUnknown( + platform: FirebaseDetectionResult['platform'], +): boolean { + return platformNeedsAndroid(platform) || platform === 'unknown'; +} + +export function platformNeedsIosWithUnknown( + platform: FirebaseDetectionResult['platform'], +): boolean { + return platformNeedsIos(platform) || platform === 'unknown'; +} + +export function hasValidFirebaseConfigFiles(result: FirebaseDetectionResult): boolean { + const needsAndroid = platformNeedsAndroidWithUnknown(result.platform); + const needsIos = platformNeedsIosWithUnknown(result.platform); + const hasAndroidConfig = !needsAndroid || Boolean(result.android?.valid); + const hasIosConfig = !needsIos || Boolean(result.ios?.valid); + + return hasAndroidConfig && hasIosConfig; +} + +export function getProjectIdFromResult(result: FirebaseDetectionResult | null): string | null { + if (result?.android?.content && 'project_info' in result.android.content) { + return result.android.content.project_info?.project_id ?? null; + } + + if (result?.ios?.content && 'PROJECT_ID' in result.ios.content) { + return result.ios.content.PROJECT_ID; + } + + return null; +} + +async function readClipboard(): Promise { + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execFileAsync = promisify(execFile); + + try { + if (process.platform === 'darwin') { + const { stdout } = await execFileAsync('pbpaste', []); + return stdout; + } + + if (process.platform === 'linux') { + try { + const { stdout } = await execFileAsync('xclip', ['-selection', 'clipboard', '-o']); + return stdout; + } catch { + const { stdout } = await execFileAsync('xsel', ['--clipboard', '--output']); + return stdout; + } + } + } catch { + return null; + } + + return null; +} + +export { + CheckingSenderConfigPhase as FirebaseServiceAccountCheckingSenderConfigTask, + DetectingPhase as FirebaseServiceAccountDetectingTask, + ErrorPhase as FirebaseServiceAccountErrorTask, + PasteServiceAccountPhase as FirebaseServiceAccountPasteTask, + RegistrationFailedPhase as FirebaseServiceAccountRegistrationFailedTask, + RegisteringSenderConfigPhase as FirebaseServiceAccountRegisteringTask, + SavingServiceAccountPhase as FirebaseServiceAccountSavingTask, + SenderConfigRegisteredPhase as FirebaseServiceAccountRegisteredTask, +}; + +function DetectingPhase(): React.ReactElement { + return ( + + + Firebase Service Account + + + + + + Detecting Firebase configuration files... + + + ); +} + +function CheckingSenderConfigPhase(): React.ReactElement { + return ( + + + Firebase Service Account + + + + + + Checking existing sender config... + + + ); +} + +function SenderConfigRegisteredPhase({ + updatedAt, + onContinue, + onCancel, +}: { + updatedAt: string | null; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onContinue(); + } + }); + + useCancelInput(onCancel); + + const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : 'unknown'; + + return ( + + + Firebase Service Account + + ✓ Firebase Service Account is already registered + Configured at: {formattedDate} + + Press Enter to continue, Esc/Ctrl+C to cancel + + + ); +} + +function PasteServiceAccountPhase({ + projectId, + onSubmit, + onCancel, +}: { + projectId: string; + onSubmit: (json: ServiceAccountJson) => void; + onCancel: () => void; +}): React.ReactElement { + const [input, setInput] = useState(''); + const [validation, setValidation] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingText, setLoadingText] = useState(''); + + useCancelInput(onCancel); + + const processJson = useCallback( + (content: string, source: string) => { + const parsed = parseServiceAccountJson(content); + setValidation(parsed); + + if (!parsed.valid || !parsed.data) { + setErrorMsg(`Invalid Service Account JSON (from ${source})`); + return; + } + + onSubmit(parsed.data); + }, + [onSubmit], + ); + + const handleSubmit = useCallback(async () => { + const trimmed = input.trim(); + setErrorMsg(null); + setValidation(null); + setLoading(true); + + try { + if (!trimmed) { + setLoadingText('Reading from clipboard...'); + const clipboard = await readClipboard(); + if (!clipboard?.trim()) { + setErrorMsg('Clipboard is empty. Copy the JSON content first.'); + return; + } + + processJson(clipboard, 'clipboard'); + return; + } + + if (trimmed.startsWith('{')) { + processJson(trimmed, 'input'); + return; + } + + setLoadingText('Reading file...'); + const fileContent = await readTextFileFromInputPath(trimmed); + processJson(fileContent, 'file'); + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException).code; + if (errorCode === 'ENOENT') { + setErrorMsg('File not found. Please check the path.'); + } else { + setErrorMsg(err instanceof Error ? err.message : 'Failed to read input'); + } + } finally { + setLoading(false); + setLoadingText(''); + } + }, [input, processJson]); + + return ( + + + Firebase Service Account Setup + + Project: {projectId} + + 1. Open Firebase Console and generate a new private key JSON: + + https://console.firebase.google.com/project/{projectId}/settings/serviceaccounts/adminsdk + + + + 2. Copy JSON to clipboard and press Enter, or drag the JSON file here. + + + + {'>'} + + + + {loading && ( + + + + + {loadingText} + + )} + + {errorMsg && ( + + ✗ {errorMsg} + + )} + + {validation && !validation.valid && ( + + Validation errors: + {validation.errors.map((validationError) => ( + + • {validationError} + + ))} + + )} + + + Enter to import · Esc/Ctrl+C cancel + + + ); +} + +function SavingServiceAccountPhase(): React.ReactElement { + return ( + + + Saving Service Account + + + + + + Saving service account key... + + + ); +} + +function RegisteringSenderConfigPhase(): React.ReactElement { + return ( + + + Firebase Service Account + + + + + + Registering sender config... + + + ); +} + +function RegistrationFailedPhase({ + error, + onRetry, + onCancel, +}: { + error: string; + onRetry: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onRetry(); + } + }); + + useCancelInput(onCancel); + + return ( + + + Firebase Service Account + + ✗ Failed to register sender config + Reason: {error} + + Press Enter to retry, Esc/Ctrl+C to cancel + + + ); +} + +function ErrorPhase({ + error, + onRetry, + onCancel, +}: { + error: string; + onRetry: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onRetry(); + } + }); + + useCancelInput(onCancel); + + return ( + + + + Firebase Service Account Error + + + ✗ {error} + + Press Enter to retry, Esc/Ctrl+C to cancel + + + ); +} diff --git a/src/ui/components/FirebaseWizard.tsx b/src/ui/components/FirebaseWizard.tsx deleted file mode 100644 index b8657fb..0000000 --- a/src/ui/components/FirebaseWizard.tsx +++ /dev/null @@ -1,2278 +0,0 @@ -import { Box, Text, useInput } from 'ink'; -import SelectInput from 'ink-select-input'; -import Spinner from 'ink-spinner'; -import TextInput from 'ink-text-input'; -import type React from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { openBrowser } from '@/lib/auth/browser'; -import type { ProjectType } from '@/lib/config'; -import { - type AndroidApp, - type CredentialAction, - FIREBASE_HELP_URLS, - type FirebaseDetectionResult, - FirebaseDownloader, - type FirebaseProject, - FirebaseService, - type FirebaseSetupResult, - type GcpProject, - type IosApp, - isOAuthConfigured, - parseServiceAccountJson, - platformNeedsAndroid, - platformNeedsIos, - type ServiceAccountJson, - type ServiceAccountValidationResult, -} from '@/lib/services/firebase'; -import { detectProjectType } from '@/lib/services/project-detector'; -import { OAUTH_CALLBACK_CONFIG } from '@/lib/utils/oauth'; -import { useCancelInput } from '@/ui/hooks'; -import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; -import { type ExtendedWizardPhase, transition } from './firebase-wizard-transitions'; -import { GenericSelector, type SelectorItem } from './GenericSelector'; - -interface FirebaseWizardProps { - projectPath: string; - projectType?: ProjectType; - clixProjectId?: string; - onComplete: (result: FirebaseSetupResult) => void; - onCancel?: () => void; -} - -interface MenuAction extends SelectorItem { - action: CredentialAction; -} - -/** - * No apps found context (which platform apps are missing). - */ -interface NoAppsContext { - noAndroidApps: boolean; - noIosApps: boolean; - needsAndroid: boolean; - needsIos: boolean; -} - -/** - * Check if a platform needs Android config (including unknown platform). - */ -function platformNeedsAndroidWithUnknown(platform: FirebaseDetectionResult['platform']): boolean { - return platformNeedsAndroid(platform) || platform === 'unknown'; -} - -/** - * Check if a platform needs iOS config (including unknown platform). - */ -function platformNeedsIosWithUnknown(platform: FirebaseDetectionResult['platform']): boolean { - return platformNeedsIos(platform) || platform === 'unknown'; -} - -/** - * Get Firebase project ID from detection result. - * Tries Android config first, then iOS config. - */ -function getProjectIdFromResult(result: FirebaseDetectionResult | null): string | undefined { - // Android config (GoogleServicesJson) has project_info.project_id - if (result?.android?.content && 'project_info' in result.android.content) { - return result.android.content.project_info?.project_id; - } - // iOS config (GoogleServiceInfoPlist) has PROJECT_ID - if (result?.ios?.content && 'PROJECT_ID' in result.ios.content) { - return result.ios.content.PROJECT_ID; - } - return undefined; -} - -/** - * Build Android-specific menu items. - */ -function buildAndroidMenuItems( - result: FirebaseDetectionResult, - needsAndroid: boolean, -): MenuAction[] { - if (!needsAndroid) return []; - - const items: MenuAction[] = []; - - if (!result.android?.valid) { - items.push({ - id: 'redetect-android', - label: 'Re-detect google-services.json', - description: result.android ? 'File found but invalid' : 'File not found', - action: { type: 'redetect_platform', platform: 'android' }, - }); - } - if (result.android) { - items.push({ - id: 'validate-android', - label: 'Validate google-services.json', - action: { type: 'validate', platform: 'android' }, - }); - } - items.push({ - id: 'help-android', - label: 'Help: Download google-services.json', - action: { type: 'help', topic: 'downloadConfig' }, - }); - - return items; -} - -/** - * Build iOS-specific menu items. - */ -function buildIosMenuItems(result: FirebaseDetectionResult, needsIos: boolean): MenuAction[] { - if (!needsIos) return []; - - const items: MenuAction[] = []; - - if (!result.ios?.valid) { - items.push({ - id: 'redetect-ios', - label: 'Re-detect GoogleService-Info.plist', - description: result.ios ? 'File found but invalid' : 'File not found', - action: { type: 'redetect_platform', platform: 'ios' }, - }); - } - if (result.ios) { - items.push({ - id: 'validate-ios', - label: 'Validate GoogleService-Info.plist', - action: { type: 'validate', platform: 'ios' }, - }); - } - items.push({ - id: 'help-ios', - label: 'Help: Download GoogleService-Info.plist', - action: { type: 'help', topic: 'downloadConfig' }, - }); - - return items; -} - -/** - * Build menu items based on detection result. - */ -function buildMenuItems(result: FirebaseDetectionResult): MenuAction[] { - const items: MenuAction[] = []; - - const needsAndroid = platformNeedsAndroidWithUnknown(result.platform); - const needsIos = platformNeedsIosWithUnknown(result.platform); - - // For unknown platform, show download option if either config is missing - const hasMissingConfigs = - result.platform === 'unknown' - ? !result.android?.valid || !result.ios?.valid - : (needsAndroid && !result.android?.valid) || (needsIos && !result.ios?.valid); - - // Download from Firebase option (if OAuth is configured and configs are missing) - if (hasMissingConfigs && isOAuthConfigured()) { - items.push({ - id: 'download', - label: '⬇ Download from Firebase', - description: 'Authenticate with Google and download config files', - action: { type: 'download' }, - }); - } - - // Platform-specific actions (extracted to reduce complexity) - items.push(...buildAndroidMenuItems(result, needsAndroid)); - items.push(...buildIosMenuItems(result, needsIos)); - - // Service Account setup (if OAuth is configured and any platform config is valid) - const hasValidConfig = result.android?.valid || result.ios?.valid; - if (isOAuthConfigured() && hasValidConfig) { - items.push({ - id: 'setup-service-account', - label: '🔑 Setup Service Account', - description: 'Create or configure Firebase Admin SDK credentials', - action: { type: 'setup_service_account' }, - }); - } - - // General actions - items.push({ - id: 'redetect-all', - label: 'Re-detect all Firebase files', - action: { type: 'redetect' }, - }); - - items.push({ - id: 'skip', - label: 'Skip Firebase setup', - description: 'Continue without Firebase configuration', - action: { type: 'skip' }, - }); - - if (result.configured) { - items.push({ - id: 'done', - label: 'Done', - description: 'Firebase is configured', - action: { type: 'done' }, - }); - } - - return items; -} - -/** - * Authenticating phase component. - */ -function AuthenticatingPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { - useCancelInput(onCancel); - - return ( - - - Firebase Authentication - - - - - - Opening browser for Google authentication... - - - Complete the authentication in your browser. - - - Press Esc/Ctrl+C to cancel - - - ); -} - -/** - * Project selector component. - */ -function ProjectSelector({ - projects, - onSelect, - onCancel, -}: { - projects: FirebaseProject[]; - onSelect: (project: FirebaseProject) => void; - onCancel: () => void; -}): React.ReactElement { - const items = projects.map((p) => ({ - label: p.displayName || p.projectId, - value: p, - })); - - useCancelInput(onCancel); - - return ( - - - Select Firebase Project - - onSelect(item.value)} /> - - ↑↓ navigate · Enter select · Esc/Ctrl+C cancel - - - ); -} - -/** - * App selector component. - */ -function AppSelector({ - apps, - platform, - onSelect, - onCancel, -}: { - apps: (AndroidApp | IosApp)[]; - platform: 'android' | 'ios'; - onSelect: (app: AndroidApp | IosApp) => void; - onCancel: () => void; -}): React.ReactElement { - const items = apps.map((app) => ({ - label: - app.displayName || - (platform === 'android' ? (app as AndroidApp).packageName : (app as IosApp).bundleId), - value: app, - })); - - useCancelInput(onCancel); - - const title = platform === 'android' ? 'Select Android App' : 'Select iOS App'; - - return ( - - - {title} - - onSelect(item.value)} /> - - ↑↓ navigate · Enter select · Esc/Ctrl+C cancel - - - ); -} - -/** - * Downloading phase component. - */ -function DownloadingPhase({ - platform, -}: { - platform: 'android' | 'ios' | 'both'; -}): React.ReactElement { - const message = - platform === 'both' - ? 'Downloading config files...' - : platform === 'android' - ? 'Downloading google-services.json...' - : 'Downloading GoogleService-Info.plist...'; - - return ( - - - Firebase Download - - - - - - {message} - - - ); -} - -/** - * No apps found phase component - offers to create apps. - */ -function NoAppsFoundPhase({ - context, - onCreateAndroid, - onCreateIos, - onCancel, -}: { - context: NoAppsContext; - onCreateAndroid: () => void; - onCreateIos: () => void; - onCancel: () => void; -}): React.ReactElement { - const items: Array<{ label: string; value: string }> = []; - - if (context.noAndroidApps && context.needsAndroid) { - items.push({ - label: '➕ Create Android app', - value: 'create_android', - }); - } - - if (context.noIosApps && context.needsIos) { - items.push({ - label: '➕ Create iOS app', - value: 'create_ios', - }); - } - - items.push({ - label: '← Back', - value: 'cancel', - }); - - useCancelInput(onCancel); - - const handleSelect = (item: { value: string }) => { - switch (item.value) { - case 'create_android': - onCreateAndroid(); - break; - case 'create_ios': - onCreateIos(); - break; - case 'cancel': - onCancel(); - break; - } - }; - - return ( - - - - No Apps Found - - - - No apps are registered in this Firebase project. - - - Would you like to create a new app? - - - - ↑↓ navigate · Enter select · Esc/Ctrl+C cancel - - - ); -} - -/** - * Create app input phase component. - */ -function CreateAppInputPhase({ - platform, - onSubmit, - onCancel, -}: { - platform: 'android' | 'ios'; - onSubmit: (identifier: string, displayName?: string) => void; - onCancel: () => void; -}): React.ReactElement { - const [identifier, setIdentifier] = useState(''); - const [displayName, setDisplayName] = useState(''); - const [stage, setStage] = useState<'identifier' | 'displayName'>('identifier'); - - const isAndroid = platform === 'android'; - const identifierLabel = isAndroid ? 'Package Name' : 'Bundle ID'; - const identifierPlaceholder = isAndroid ? 'com.example.app' : 'com.example.app'; - const title = isAndroid ? 'Create Android App' : 'Create iOS App'; - - useCancelInput(onCancel); - - const handleIdentifierSubmit = useCallback(() => { - if (identifier.trim()) { - setStage('displayName'); - } - }, [identifier]); - - const handleDisplayNameSubmit = useCallback(() => { - onSubmit(identifier.trim(), displayName.trim() || undefined); - }, [identifier, displayName, onSubmit]); - - return ( - - - {title} - - - {stage === 'identifier' ? ( - <> - - - {identifierLabel}: (e.g., {identifierPlaceholder}) - - - - {'> '} - - - - Enter to continue · Esc/Ctrl+C cancel - - - ) : ( - <> - - - {identifierLabel}: {identifier} - - - - - Display Name: (optional) - - - - {'> '} - - - - Enter to create · Esc/Ctrl+C cancel - - - )} - - ); -} - -/** - * Creating app phase component. - */ -function CreatingAppPhase({ platform }: { platform: 'android' | 'ios' }): React.ReactElement { - const message = platform === 'android' ? 'Creating Android app...' : 'Creating iOS app...'; - - return ( - - - Firebase App Creation - - - - - - {message} - - - This may take a few seconds... - - - ); -} - -/** - * No projects found phase component - offers to create or link Firebase project. - */ -function NoProjectsPhase({ - onOpenConsole, - onSelectGcp, - onCancel, -}: { - onOpenConsole: () => void; - onSelectGcp: () => void; - onCancel: () => void; -}): React.ReactElement { - const items = [ - { - label: '🌐 Open Firebase Console', - value: 'console', - }, - { - label: '📦 Add Firebase to existing GCP project', - value: 'gcp', - }, - { - label: '← Back', - value: 'cancel', - }, - ]; - - useCancelInput(onCancel); - - const handleSelect = (item: { value: string }) => { - switch (item.value) { - case 'console': - onOpenConsole(); - break; - case 'gcp': - onSelectGcp(); - break; - case 'cancel': - onCancel(); - break; - } - }; - - return ( - - - - No Firebase Projects Found - - - - No Firebase projects are associated with this account. - - - Create a new project or add Firebase to an existing GCP project: - - - - ↑↓ navigate · Enter select · Esc/Ctrl+C cancel - - - ); -} - -/** - * GCP project selector component. - */ -function GcpProjectSelector({ - projects, - onSelect, - onCancel, -}: { - projects: GcpProject[]; - onSelect: (project: GcpProject) => void; - onCancel: () => void; -}): React.ReactElement { - const items = projects.map((p) => ({ - label: p.name || p.projectId, - value: p, - })); - - useCancelInput(onCancel); - - return ( - - - Select GCP Project - - - Select a project to add Firebase: - - onSelect(item.value)} /> - - ↑↓ navigate · Enter select · Esc/Ctrl+C cancel - - - ); -} - -/** - * Adding Firebase to GCP project phase component. - */ -function AddingFirebasePhase({ projectId }: { projectId: string }): React.ReactElement { - return ( - - - Adding Firebase - - - - - - Adding Firebase to {projectId}... - - - This may take a moment... - - - ); -} - -/** - * Service Account menu action type. - */ -type ServiceAccountMenuAction = - | { type: 'open_console' } - | { type: 'paste_json' } - | { type: 'skip' }; - -/** - * Service Account menu phase component. - */ -function ServiceAccountMenuPhase({ - projectId, - onAction, - onCancel, -}: { - projectId: string; - onAction: (action: ServiceAccountMenuAction) => void; - onCancel: () => void; -}): React.ReactElement { - const items: Array<{ label: string; value: ServiceAccountMenuAction }> = [ - { - label: '🌐 Download from Firebase Console', - value: { type: 'open_console' }, - }, - { - label: '📄 Paste Service Account JSON', - value: { type: 'paste_json' }, - }, - { - label: '⏭ Skip (configure later)', - value: { type: 'skip' }, - }, - ]; - - useCancelInput(onCancel); - - return ( - - - Service Account Setup - - - Project: {projectId} - - - Service Account is required for server-side Firebase Admin SDK. - - - - Download the private key JSON from Firebase Console (Project Settings → Service Accounts → - Generate new private key), then paste it here. - - - onAction(item.value)} /> - - ↑↓ navigate · Enter select · Esc/Ctrl+C cancel - - - ); -} - -/** - * Import Service Account JSON phase component. - * Accepts a file path to a downloaded JSON file. - */ -/** - * Read text content from system clipboard. - * Uses pbpaste on macOS, xclip or xsel on Linux. - */ -async function readClipboard(): Promise { - const { execFile } = await import('node:child_process'); - const { promisify } = await import('node:util'); - const execFileAsync = promisify(execFile); - - const platform = process.platform; - try { - if (platform === 'darwin') { - const { stdout } = await execFileAsync('pbpaste', []); - return stdout; - } - if (platform === 'linux') { - try { - const { stdout } = await execFileAsync('xclip', ['-selection', 'clipboard', '-o']); - return stdout; - } catch { - const { stdout } = await execFileAsync('xsel', ['--clipboard', '--output']); - return stdout; - } - } - return null; - } catch { - return null; - } -} - -/** - * Read file content from a file path. - * Supports ~ home directory and relative paths. - */ -async function readFileFromPath(filePath: string): Promise { - const fs = await import('node:fs/promises'); - const path = await import('node:path'); - - // Strip surrounding quotes (from drag & drop on some terminals) - const cleaned = filePath.replace(/^['"]|['"]$/g, ''); - const resolved = cleaned.startsWith('~') - ? path.join(process.env.HOME || '', cleaned.slice(1)) - : path.resolve(cleaned); - - return fs.readFile(resolved, 'utf-8'); -} - -function PasteServiceAccountPhase({ - onSubmit, - onCancel, -}: { - onSubmit: (json: ServiceAccountJson) => void; - onCancel: () => void; -}): React.ReactElement { - const [input, setInput] = useState(''); - const [validation, setValidation] = useState(null); - const [errorMsg, setErrorMsg] = useState(null); - const [loading, setLoading] = useState(false); - const [loadingText, setLoadingText] = useState(''); - - useCancelInput(onCancel); - - const processJson = useCallback( - (content: string, source: string) => { - const result = parseServiceAccountJson(content); - if (result.valid && result.data) { - setValidation(result); - onSubmit(result.data); - } else { - setValidation(result); - setErrorMsg(`Invalid Service Account JSON (from ${source})`); - } - }, - [onSubmit], - ); - - const handleSubmit = useCallback(async () => { - const trimmed = input.trim(); - - setLoading(true); - setErrorMsg(null); - setValidation(null); - - try { - if (!trimmed) { - // Empty input → read from clipboard - setLoadingText('Reading from clipboard...'); - const clipboard = await readClipboard(); - if (!clipboard?.trim()) { - setErrorMsg('Clipboard is empty. Copy the JSON content first.'); - return; - } - processJson(clipboard, 'clipboard'); - } else if (trimmed.startsWith('{')) { - // JSON-like input → parse directly - processJson(trimmed, 'input'); - } else { - // File path → read file - setLoadingText('Reading file...'); - const content = await readFileFromPath(trimmed); - processJson(content, 'file'); - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - setErrorMsg('File not found. Please check the path.'); - } else { - setErrorMsg(err instanceof Error ? err.message : 'Failed to read input'); - } - } finally { - setLoading(false); - setLoadingText(''); - } - }, [input, processJson]); - - return ( - - - Import Service Account JSON - - - - Firebase Console → Project Settings → Service accounts → Generate new private key - - - - 1. Copy JSON to clipboard → press Enter - 2. Drag the JSON file here → press Enter - - - - {'> '} - - - - {loading && ( - - - - - {loadingText} - - )} - - {errorMsg && ( - - ✗ {errorMsg} - - )} - - {validation && !validation.valid && ( - - Validation errors: - {validation.errors.map((err) => ( - - • {err} - - ))} - - )} - - - Enter to import · Esc/Ctrl+C cancel - - - ); -} - -/** - * Saving Service Account phase component. - */ -function SavingServiceAccountPhase(): React.ReactElement { - return ( - - - Saving Service Account - - - - - - Saving service account key... - - - ); -} - -/** - * Checking sender config phase component. - */ -function CheckingSenderConfigPhase(): React.ReactElement { - return ( - - - Firebase Configuration - - - - - - Checking push notification configuration... - - - ); -} - -/** - * Sender config already registered phase component. - */ -function SenderConfigRegisteredPhase({ - updatedAt, - onContinue, - onCancel, -}: { - updatedAt: string | null; - onContinue: () => void; - onCancel: () => void; -}): React.ReactElement { - useInput((_input, key) => { - if (key.return) { - onContinue(); - } - }); - - useCancelInput(onCancel); - - const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : 'unknown'; - - return ( - - - Push Notification Configuration - - - ✓ FCM sender config is already registered - - - Configured at: {formattedDate} - - - Press Enter to continue, Esc/Ctrl+C to cancel - - - ); -} - -/** - * Registering sender config phase component. - */ -function RegisteringSenderConfigPhase({ - result, -}: { - result: 'success' | 'failed' | null; -}): React.ReactElement { - return ( - - - Push Notification Configuration - - {result === 'success' ? ( - - ✓ Push notification configured successfully - - ) : result === 'failed' ? ( - - - ✗ Failed to register sender config (can be configured later in Console) - - - ) : ( - - - - - Registering push notification configuration... - - )} - - ); -} - -/** - * Detecting phase component. - */ -function DetectingPhase(): React.ReactElement { - return ( - - - Firebase Configuration - - - - - - Detecting Firebase credentials... - - - ); -} - -/** - * Status phase component. - */ -function StatusPhase({ - result, - onContinue, - onSkip, -}: { - result: FirebaseDetectionResult; - onContinue: () => void; - onSkip: () => void; -}): React.ReactElement { - useInput((_input, key) => { - if (key.return) { - onContinue(); - } - }); - - useCancelInput(onSkip); - - return ( - - - - Press Enter to configure, Esc/Ctrl+C to skip - - - ); -} - -/** - * Menu phase component. - */ -function MenuPhase({ - result, - onAction, - onCancel, -}: { - result: FirebaseDetectionResult; - onAction: (action: CredentialAction) => void; - onCancel: () => void; -}): React.ReactElement { - const items = buildMenuItems(result); - - const handleSelect = useCallback( - (item: MenuAction) => { - onAction(item.action); - }, - [onAction], - ); - - return ( - - ); -} - -/** - * Validating phase component. - */ -function ValidatingPhase({ platform }: { platform: 'android' | 'ios' }): React.ReactElement { - const fileName = platform === 'android' ? 'google-services.json' : 'GoogleService-Info.plist'; - - return ( - - - Firebase Configuration - - - - - - Validating {fileName}... - - - ); -} - -/** - * Check if an error is a scope insufficient error. - */ -function isScopeInsufficientError(error: string): boolean { - return ( - error.includes('ACCESS_TOKEN_SCOPE_INSUFFICIENT') || - error.includes('insufficient authentication scopes') || - (error.includes('403') && error.includes('PERMISSION_DENIED')) - ); -} - -/** - * Get helpful hint message based on error type. - */ -function getErrorHint(error: string): string | null { - if (isScopeInsufficientError(error)) { - return `Your OAuth token has insufficient permissions. -This usually happens when the required permissions have changed. -Press Enter to re-authenticate with updated permissions.`; - } - if (error.includes('invalid_client')) { - return `The OAuth client ID is invalid or not configured correctly. -Check your CLIX_GOOGLE_CLIENT_ID environment variable.`; - } - if (error.includes('redirect_uri_mismatch')) { - return `The redirect URI doesn't match your OAuth client configuration. -Add this to your OAuth client: ${OAUTH_CALLBACK_CONFIG.getCallbackUrlIp()}`; - } - if (error.includes('invalid_grant')) { - return `The authorization code has expired or already been used. -Please try again.`; - } - if (error.includes('invalid_request')) { - return `The OAuth request was malformed or missing required parameters. -This can happen when: -- OAuth client redirect URI is misconfigured -- Required PKCE parameters are missing or invalid -- Browser session expired before completing auth - -Check .clix/debug.log for detailed error information.`; - } - if (error.includes('API has not been used in project') || error.includes('it is disabled')) { - return `A required Google Cloud API is not enabled for this project. -Visit the URL shown above to enable it in Google Cloud Console. -After enabling, wait a few minutes then press Enter to retry.`; - } - return null; -} - -/** - * Error phase component. - */ -function ErrorPhase({ - error, - onRetry, - onSkip, -}: { - error: string; - onRetry: () => void; - onSkip: () => void; -}): React.ReactElement { - useInput((_input, key) => { - if (key.return) { - onRetry(); - } - }); - - useCancelInput(onSkip); - - const hint = getErrorHint(error); - - return ( - - - - Firebase Configuration Error - - - - ✗ {error} - - {hint && ( - - {hint} - - )} - {!hint && ( - - See .clix/debug.log for details - - )} - - Press Enter to retry, Esc/Ctrl+C to skip - - - ); -} - -/** - * Complete phase component. - */ -function CompletePhase({ - result, - skipped, -}: { - result: FirebaseDetectionResult | null; - skipped: boolean; -}): React.ReactElement { - if (skipped) { - return ( - - - - ! Firebase setup skipped - - - - You can run /firebase later to configure Firebase. - - - ); - } - - return ( - - - - ✓ Firebase Configuration Complete - - - {result && } - - ); -} - -/** - * Firebase setup wizard component. - * - * Guides users through Firebase configuration detection and validation. - */ -export const FirebaseWizard: React.FC = ({ - projectPath, - projectType: propProjectType, - clixProjectId, - onComplete, - onCancel, -}) => { - const [phase, setPhase] = useState('detecting'); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - const [validatingPlatform, setValidatingPlatform] = useState<'android' | 'ios' | null>(null); - const [skipped, setSkipped] = useState(false); - - // Download flow state - const [downloader] = useState(() => new FirebaseDownloader()); - const [projects, setProjects] = useState([]); - const [selectedProject, setSelectedProject] = useState(null); - const [androidApps, setAndroidApps] = useState([]); - const [iosApps, setIosApps] = useState([]); - const [selectedAndroidApp, setSelectedAndroidApp] = useState(null); - const [downloadingPlatform, setDownloadingPlatform] = useState<'android' | 'ios' | 'both'>( - 'both', - ); - - // App creation flow state - const [noAppsContext, setNoAppsContext] = useState(null); - const [creatingAppPlatform, setCreatingAppPlatform] = useState<'android' | 'ios'>('android'); - - // GCP project flow state (for adding Firebase to existing GCP project) - const [gcpProjects, setGcpProjects] = useState([]); - const [selectedGcpProject, setSelectedGcpProject] = useState(null); - - // Service Account flow state - const [, setServiceAccountJson] = useState(null); - - // Sender config state - const [senderConfigUpdatedAt, setSenderConfigUpdatedAt] = useState(null); - const [senderConfigResult, setSenderConfigResult] = useState<'success' | 'failed' | null>(null); - - const [service, setService] = useState(null); - const [projectType, setProjectType] = useState(propProjectType ?? null); - - // Initial detection - useEffect(() => { - const detect = async () => { - try { - const detectedProjectType = propProjectType ?? (await detectProjectType(projectPath)); - setProjectType(detectedProjectType); - const firebaseService = new FirebaseService(projectPath, detectedProjectType); - setService(firebaseService); - const detectionResult = await firebaseService.detect(); - setResult(detectionResult); - setPhase(transition('detecting', 'success')); - } catch (err) { - setError(err instanceof Error ? err.message : 'Detection failed'); - setPhase(transition('detecting', 'error')); - } - }; - - if (phase === 'detecting') { - detect(); - } - }, [phase, projectPath, propProjectType]); - - // Sender config check effect - useEffect(() => { - if (phase !== 'checking_sender_config') return; - - const checkSenderConfig = async () => { - // Skip API check if no Clix project linked, but still go to SA setup - if (!clixProjectId) { - setPhase(transition('checking_sender_config', 'skip')); - return; - } - - try { - const { getInternalApiClient } = await import('@/lib/api'); - const apiClient = getInternalApiClient(); - const project = await apiClient.getProject(clixProjectId); - const pushConfig = project.sender_configs?.find( - (c) => c.channel_type === 'CHANNEL_TYPE_APP_PUSH', - ); - - if (pushConfig) { - setSenderConfigUpdatedAt(pushConfig.updated_at ?? pushConfig.created_at ?? null); - setPhase(transition('checking_sender_config', 'registered')); - } else { - setPhase(transition('checking_sender_config', 'not_registered')); - } - } catch { - // API error should not block setup, still go to SA menu - setPhase(transition('checking_sender_config', 'error')); - } - }; - - checkSenderConfig(); - }, [phase, clixProjectId]); - - const handleContinue = useCallback(() => { - setPhase(transition('status', 'continue')); - }, []); - - const handleSkip = useCallback(() => { - setSkipped(true); - setPhase(transition('menu', 'skip')); - onComplete({ - completed: false, - skipped: true, - detection: result, - }); - }, [onComplete, result]); - - // Helper to determine which platforms need config files - const getPlatformNeeds = useCallback(() => { - const platform = result?.platform ?? 'unknown'; - const needsAndroid = platformNeedsAndroidWithUnknown(platform); - const needsIos = platformNeedsIosWithUnknown(platform); - const needsAndroidConfig = needsAndroid && !result?.android?.valid; - const needsIosConfig = needsIos && !result?.ios?.valid; - return { needsAndroid, needsIos, needsAndroidConfig, needsIosConfig }; - }, [result]); - - // Download config files - defined first as it's called by other handlers via ref - const handleDownloadConfigs = useCallback( - async (project: FirebaseProject, androidApp: AndroidApp | null, iosApp: IosApp | null) => { - if (!projectType) { - setError('Project type not detected'); - setPhase(transition('select_project', 'error')); - return; - } - - if (androidApp && iosApp) { - setDownloadingPlatform('both'); - } else if (androidApp) { - setDownloadingPlatform('android'); - } else { - setDownloadingPlatform('ios'); - } - setPhase('downloading'); // Direct set: entry point from multiple phases - - try { - const paths = downloader.getExpectedSavePaths(projectPath, projectType); - - if (androidApp && paths.android) { - await downloader.downloadAndroidConfig( - project.projectId, - androidApp.appId, - paths.android, - ); - } - - if (iosApp && paths.ios) { - await downloader.downloadIosConfig(project.projectId, iosApp.appId, paths.ios); - } - - // Re-detect to verify - if (service) { - const newResult = await service.detect(); - setResult(newResult); - } - setPhase(transition('downloading', 'success')); - } catch (err) { - setError(err instanceof Error ? err.message : 'Download failed'); - setPhase(transition('downloading', 'error')); - } - }, - [projectPath, projectType, downloader, service], - ); - - // Use ref to avoid circular dependencies - const downloadConfigsRef = useRef(handleDownloadConfigs); - downloadConfigsRef.current = handleDownloadConfigs; - - // Handle iOS app selection - const handleIosAppSelect = useCallback( - (app: IosApp, project: FirebaseProject) => { - downloadConfigsRef.current(project, selectedAndroidApp, app); - }, - [selectedAndroidApp], - ); - - // Use ref for iOS app select handler - const iosAppSelectRef = useRef(handleIosAppSelect); - iosAppSelectRef.current = handleIosAppSelect; - - // Handle Android app selection - const handleAndroidAppSelect = useCallback( - async (app: AndroidApp, project: FirebaseProject, needsIos: boolean) => { - setSelectedAndroidApp(app); - - if (!needsIos) { - downloadConfigsRef.current(project, app, null); - return; - } - - // Also need iOS config - try { - const apps = await downloader.listIosApps(project.projectId); - setIosApps(apps); - - if (apps.length === 0) { - // No iOS apps, just download Android - downloadConfigsRef.current(project, app, null); - } else if (apps.length === 1) { - downloadConfigsRef.current(project, app, apps[0]); - } else { - setPhase(transition('select_android_app', 'select_ios_app')); - } - } catch { - // Failed to get iOS apps, just download Android - downloadConfigsRef.current(project, app, null); - } - }, - [downloader], - ); - - // Use ref for Android app select handler - const androidAppSelectRef = useRef(handleAndroidAppSelect); - androidAppSelectRef.current = handleAndroidAppSelect; - - // Fetch and handle Android apps for a project - const fetchAndHandleAndroidApps = useCallback( - async (project: FirebaseProject, needsIosConfig: boolean) => { - const apps = await downloader.listAndroidApps(project.projectId); - setAndroidApps(apps); - - if (apps.length === 0) { - return false; // No Android apps found - } - - if (apps.length === 1) { - androidAppSelectRef.current(apps[0], project, needsIosConfig); - } else { - setPhase(transition('select_project', 'select_android_app')); - } - return true; - }, - [downloader], - ); - - // Fetch and handle iOS apps for a project - const fetchAndHandleIosApps = useCallback( - async (project: FirebaseProject) => { - const apps = await downloader.listIosApps(project.projectId); - setIosApps(apps); - - if (apps.length === 0) { - return false; // No iOS apps found - } - - if (apps.length === 1) { - iosAppSelectRef.current(apps[0], project); - } else { - setPhase(transition('select_project', 'select_ios_app')); - } - return true; - }, - [downloader], - ); - - // Handle project selection - fetch apps for required platforms - const fetchAppsForPlatforms = useCallback( - async ( - project: FirebaseProject, - shouldFetchAndroid: boolean, - shouldFetchIos: boolean, - ): Promise<{ noAndroidApps: boolean; noIosApps: boolean }> => { - let noAndroidApps = false; - let noIosApps = false; - - if (shouldFetchAndroid) { - const hasAndroidApps = await fetchAndHandleAndroidApps(project, shouldFetchIos); - if (!hasAndroidApps) { - noAndroidApps = true; - if (shouldFetchIos) { - const hasIosApps = await fetchAndHandleIosApps(project); - noIosApps = !hasIosApps; - } - } - } else if (shouldFetchIos) { - const hasIosApps = await fetchAndHandleIosApps(project); - noIosApps = !hasIosApps; - } - - return { noAndroidApps, noIosApps }; - }, - [fetchAndHandleAndroidApps, fetchAndHandleIosApps], - ); - - // Handle project selection - const handleProjectSelect = useCallback( - async (project: FirebaseProject) => { - setSelectedProject(project); - const { needsAndroid, needsIos, needsAndroidConfig, needsIosConfig } = getPlatformNeeds(); - - const platform = result?.platform ?? 'unknown'; - const forceDownload = platform === 'unknown'; - const shouldFetchAndroid = needsAndroidConfig || (forceDownload && needsAndroid); - const shouldFetchIos = needsIosConfig || (forceDownload && needsIos); - - if (!shouldFetchAndroid && !shouldFetchIos) { - setError('No configuration files needed for this platform.'); - setPhase(transition('select_project', 'error')); - return; - } - - try { - const { noAndroidApps, noIosApps } = await fetchAppsForPlatforms( - project, - shouldFetchAndroid, - shouldFetchIos, - ); - - if ((shouldFetchAndroid && noAndroidApps) || (shouldFetchIos && noIosApps)) { - setNoAppsContext({ - noAndroidApps, - noIosApps, - needsAndroid: shouldFetchAndroid, - needsIos: shouldFetchIos, - }); - setPhase(transition('select_project', 'no_apps_found')); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch apps'); - setPhase(transition('select_project', 'error')); - } - }, - [getPlatformNeeds, fetchAppsForPlatforms, result], - ); - - // Use ref for project select handler - const projectSelectRef = useRef(handleProjectSelect); - projectSelectRef.current = handleProjectSelect; - - // Handle download authentication - const handleDownload = useCallback(async () => { - setPhase('authenticating'); // Direct set: entry point from menu/download action - - try { - // Check if already authenticated - const isAuth = await downloader.isAuthenticated(); - - if (!isAuth) { - // Start OAuth flow - const authResult = await downloader.authenticate(openBrowser); - if (!authResult.success) { - setError(authResult.error || 'Authentication failed. Please try again.'); - setPhase(transition('authenticating', 'error')); - return; - } - } - - // Fetch projects - const fetchedProjects = await downloader.listProjects(); - if (fetchedProjects.length === 0) { - // No Firebase projects - show options to create or add Firebase to GCP project - setPhase(transition('authenticating', 'no_projects')); - return; - } - - setProjects(fetchedProjects); - - // Auto-select if only one project - if (fetchedProjects.length === 1) { - projectSelectRef.current(fetchedProjects[0]); - } else { - setPhase(transition('authenticating', 'select_project')); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Authentication failed'); - setPhase(transition('authenticating', 'error')); - } - }, [downloader]); - - // Handler for Service Account setup (defined before handleAction to be used in it) - const handleServiceAccountSetup = useCallback(() => { - // Get project ID from detection result - const projectId = getProjectIdFromResult(result); - if (!projectId) { - setError('No Firebase project configured. Please download config files first.'); - setPhase('error'); - return; - } - - setPhase(transition('menu', 'setup_service_account')); - }, [result]); - - const handleAction = useCallback( - async (action: CredentialAction) => { - switch (action.type) { - case 'download': - await handleDownload(); - break; - - case 'redetect': - setPhase(transition('menu', 'redetect')); - break; - - case 'redetect_platform': - setValidatingPlatform(action.platform); - setPhase(transition('menu', 'redetect_platform')); - break; - - case 'validate': - setValidatingPlatform(action.platform); - setPhase(transition('menu', 'validate')); - // Re-detect to validate - try { - if (service) { - const newResult = await service.detect(); - setResult(newResult); - } - setPhase(transition('validating', 'success')); - } catch (err) { - setError(err instanceof Error ? err.message : 'Validation failed'); - setPhase(transition('validating', 'error')); - } - break; - - case 'help': { - const url = FIREBASE_HELP_URLS[action.topic]; - openBrowser(url); - break; - } - - case 'skip': - handleSkip(); - break; - - case 'done': - setPhase(transition('menu', 'done')); - onComplete({ - completed: true, - skipped: false, - detection: result, - }); - break; - - case 'setup_service_account': - // Start service account setup flow - await handleServiceAccountSetup(); - break; - } - }, - [service, handleSkip, handleDownload, onComplete, result, handleServiceAccountSetup], - ); - - const handleRetry = useCallback(async () => { - // Check if this was a scope error or invalid_grant - if so, logout and re-authenticate - if (error && (isScopeInsufficientError(error) || error.includes('invalid_grant'))) { - // Clear tokens and trigger re-authentication - await downloader.logout(); - setError(null); - // Go back to download flow which will re-authenticate - await handleDownload(); - return; - } - - setError(null); - setPhase(transition('error', 'retry')); - }, [error, downloader, handleDownload]); - - const handleCancel = useCallback(() => { - if (onCancel) { - onCancel(); - } else { - handleSkip(); - } - }, [onCancel, handleSkip]); - - // Handle creating an app - const handleCreateApp = useCallback( - async (platform: 'android' | 'ios', identifier: string, displayName?: string) => { - if (!selectedProject) { - setError('No project selected.'); - setPhase('error'); - return; - } - - setCreatingAppPlatform(platform); - setPhase('creating_app'); // Direct set: entry point from create_android_app/create_ios_app - - try { - if (platform === 'android') { - const app = await downloader.createAndroidApp(selectedProject.projectId, { - packageName: identifier, - displayName, - }); - setAndroidApps([app]); - setSelectedAndroidApp(app); - - // Check if we also need iOS - const { needsIos } = getPlatformNeeds(); - if (needsIos && noAppsContext?.noIosApps) { - // We need iOS too, go back to no apps found to create iOS - setNoAppsContext({ - ...noAppsContext, - noAndroidApps: false, - }); - setPhase(transition('creating_app', 'no_apps_found')); - } else if (needsIos) { - // Fetch iOS apps - const iosAppsList = await downloader.listIosApps(selectedProject.projectId); - if (iosAppsList.length === 0) { - // Download Android config only, then show no apps for iOS - downloadConfigsRef.current(selectedProject, app, null); - } else if (iosAppsList.length === 1) { - downloadConfigsRef.current(selectedProject, app, iosAppsList[0]); - } else { - setIosApps(iosAppsList); - setPhase(transition('creating_app', 'select_ios_app')); - } - } else { - // Only needed Android, download config - downloadConfigsRef.current(selectedProject, app, null); - } - } else { - const app = await downloader.createIosApp(selectedProject.projectId, { - bundleId: identifier, - displayName, - }); - setIosApps([app]); - - // Download config with existing Android app if any - downloadConfigsRef.current(selectedProject, selectedAndroidApp, app); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create app'); - setPhase(transition('creating_app', 'error')); - } - }, - [selectedProject, downloader, getPlatformNeeds, noAppsContext, selectedAndroidApp], - ); - - // Handlers for no apps found phase - const handleStartCreateAndroid = useCallback(() => { - setCreatingAppPlatform('android'); - setPhase(transition('no_apps_found', 'create_android')); - }, []); - - const handleStartCreateIos = useCallback(() => { - setCreatingAppPlatform('ios'); - setPhase(transition('no_apps_found', 'create_ios')); - }, []); - - const handleCreateAndroidSubmit = useCallback( - (identifier: string, displayName?: string) => { - handleCreateApp('android', identifier, displayName); - }, - [handleCreateApp], - ); - - const handleCreateIosSubmit = useCallback( - (identifier: string, displayName?: string) => { - handleCreateApp('ios', identifier, displayName); - }, - [handleCreateApp], - ); - - const handleNoAppsCancel = useCallback(() => { - // Go back to project selection - setPhase(transition('no_apps_found', 'cancel')); - }, []); - - // Handler for opening Firebase Console - const handleOpenFirebaseConsole = useCallback(() => { - openBrowser('https://console.firebase.google.com/'); - // Go back to menu after opening - setPhase(transition('no_projects', 'open_console')); - }, []); - - // Handler for fetching GCP projects (to add Firebase) - const handleFetchGcpProjects = useCallback(async () => { - setPhase('authenticating'); // Direct set: entry point from no_projects - try { - const availableGcpProjects = await downloader.listAvailableGcpProjects(); - if (availableGcpProjects.length === 0) { - setError('No GCP projects available to add Firebase. Create a project first.'); - setPhase(transition('authenticating', 'error')); - return; - } - setGcpProjects(availableGcpProjects); - setPhase(transition('authenticating', 'select_gcp_project')); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch GCP projects'); - setPhase(transition('authenticating', 'error')); - } - }, [downloader]); - - // Handler for GCP project selection - const handleGcpProjectSelect = useCallback( - async (project: GcpProject) => { - setSelectedGcpProject(project); - setPhase(transition('select_gcp_project', 'adding_firebase')); - - try { - const firebaseProject = await downloader.addFirebaseToProject(project.projectId); - // Add to projects list and select it - setProjects([firebaseProject]); - projectSelectRef.current(firebaseProject); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add Firebase to project'); - setPhase(transition('adding_firebase', 'error')); - } - }, - [downloader], - ); - - // Handler for Service Account menu action - const handleServiceAccountMenuAction = useCallback( - (action: ServiceAccountMenuAction) => { - const projectId = getProjectIdFromResult(result); - if (!projectId) { - setError('No project ID found'); - setPhase(transition('service_account_menu', 'error')); - return; - } - - switch (action.type) { - case 'open_console': - openBrowser( - `https://console.firebase.google.com/project/${projectId}/settings/serviceaccounts/adminsdk`, - ); - // Stay on menu so user can paste after downloading - setPhase(transition('service_account_menu', 'open_console')); - break; - - case 'paste_json': - setPhase(transition('service_account_menu', 'paste_json')); - break; - - case 'skip': - setPhase(transition('service_account_menu', 'skip')); - break; - } - }, - [result], - ); - - // Handler for pasting service account JSON - // Only saves the file, then transitions to registering_sender_config or complete - const handleSaveServiceAccountJson = useCallback( - async (json: ServiceAccountJson) => { - setPhase(transition('paste_service_account', 'save')); - - try { - setServiceAccountJson(json); - await downloader.saveServiceAccountJson(projectPath, json); - - // Decide next phase: register sender config if Clix project is linked - if (clixProjectId) { - setPhase(transition('saving_service_account', 'register')); - } else { - setPhase(transition('saving_service_account', 'complete')); - onComplete({ completed: true, skipped: false, detection: result }); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save service account'); - setPhase(transition('saving_service_account', 'error')); - } - }, - [downloader, projectPath, clixProjectId, onComplete, result], - ); - - // Sender config registration effect - triggered when entering registering_sender_config phase - useEffect(() => { - if (phase !== 'registering_sender_config' || !clixProjectId) return; - - const registerSenderConfig = async () => { - try { - const { getInternalApiClient } = await import('@/lib/api'); - const apiClient = getInternalApiClient(); - const saJsonPath = `${projectPath}/.clix/firebase-service-account.json`; - const { readFile } = await import('node:fs/promises'); - const saContent = await readFile(saJsonPath, 'utf-8'); - const encoded = btoa(saContent); - await apiClient.createOrUpdateSenderConfig(clixProjectId, { - channel_type: 'CHANNEL_TYPE_APP_PUSH', - app_push: { - ios_config: { fcm_sa_json_base64_encoded: encoded }, - android_config: { fcm_sa_json_base64_encoded: encoded }, - }, - }); - setSenderConfigResult('success'); - } catch { - setSenderConfigResult('failed'); - } - - // Auto-complete after brief display - setTimeout(() => { - setPhase(transition('registering_sender_config', 'complete')); - onComplete({ completed: true, skipped: false, detection: result }); - }, 1500); - }; - - registerSenderConfig(); - }, [phase, clixProjectId, projectPath, onComplete, result]); - - // Handler for going back to service account menu - const handleServiceAccountCancel = useCallback(() => { - setPhase(transition('paste_service_account', 'cancel')); - }, []); - - // Handler for sender config registered → complete wizard - const handleSenderConfigContinue = useCallback(() => { - setPhase(transition('sender_config_registered', 'continue')); - onComplete({ completed: true, skipped: false, detection: result }); - }, [onComplete, result]); - - switch (phase) { - case 'detecting': - return ; - - case 'status': - if (!result) { - return ; - } - return ; - - case 'menu': - if (!result) { - return ; - } - return ; - - case 'validating': - return ; - - case 'authenticating': - return ; - - case 'select_project': - return ( - - ); - - case 'select_android_app': - return ( - - selectedProject && - handleAndroidAppSelect( - app as AndroidApp, - selectedProject, - (result?.platform === 'ios' || - result?.platform === 'react-native' || - result?.platform === 'flutter') && - !result?.ios?.valid, - ) - } - onCancel={handleCancel} - /> - ); - - case 'select_ios_app': - return ( - selectedProject && handleIosAppSelect(app as IosApp, selectedProject)} - onCancel={handleCancel} - /> - ); - - case 'downloading': - return ; - - case 'no_apps_found': - return ( - - ); - - case 'create_android_app': - return ( - - ); - - case 'create_ios_app': - return ( - - ); - - case 'creating_app': - return ; - - // New phases for no projects and service account - case 'no_projects': - return ( - - ); - - case 'select_gcp_project': - return ( - - ); - - case 'adding_firebase': - return ; - - case 'service_account_menu': - return ( - - ); - - case 'paste_service_account': - return ( - - ); - - case 'saving_service_account': - return ; - - case 'checking_sender_config': - return ; - - case 'sender_config_registered': - return ( - - ); - - case 'registering_sender_config': - return ; - - case 'error': - return ( - - ); - - case 'complete': - return ; - - default: - return ; - } -}; diff --git a/src/ui/components/GenericSelector.tsx b/src/ui/components/GenericSelector.tsx index 5113bfa..f7e9ee7 100644 --- a/src/ui/components/GenericSelector.tsx +++ b/src/ui/components/GenericSelector.tsx @@ -3,6 +3,10 @@ import type React from 'react'; import { useState } from 'react'; import { useCancelInput } from '@/ui/hooks'; +const FALLBACK_TERMINAL_WIDTH = 80; +const SELECTOR_CHROME_WIDTH = 6; +const MIN_SELECTOR_LINE_WIDTH = 10; + export interface SelectorItem { id: string; label: string; @@ -20,6 +24,71 @@ interface GenericSelectorProps { currentItemId?: string; emptyMessage?: string; helpText?: string; + showBorder?: boolean; +} + +function fitToWidth(text: string, width: number): string { + const clipped = text.length > width ? text.slice(0, width) : text; + return clipped.padEnd(width, ' '); +} + +export function getSelectorLineWidth(columns = process.stdout.columns): number { + const terminalWidth = + typeof columns === 'number' && Number.isFinite(columns) && columns > 0 + ? columns + : FALLBACK_TERMINAL_WIDTH; + return Math.max(terminalWidth - SELECTOR_CHROME_WIDTH, MIN_SELECTOR_LINE_WIDTH); +} + +export function formatDefaultSelectorLine( + item: SelectorItem, + options: { + isSelected: boolean; + isCurrent: boolean; + width: number; + }, +): string { + const prefix = options.isSelected ? '› ' : ' '; + const currentTag = options.isCurrent ? ' (current)' : ''; + const description = item.description ? ` ${item.description}` : ''; + return fitToWidth(`${prefix}${item.label}${currentTag}${description}`, options.width); +} + +function splitDefaultSelectorLine( + item: SelectorItem, + options: { + isSelected: boolean; + isCurrent: boolean; + width: number; + }, +): { + prefix: string; + label: string; + currentTag: string; + description: string; + padding: string; +} { + const prefixRaw = options.isSelected ? '› ' : ' '; + const labelRaw = item.label; + const currentTagRaw = options.isCurrent ? ' (current)' : ''; + const descriptionRaw = item.description ? ` ${item.description}` : ''; + const fullLine = formatDefaultSelectorLine(item, options); + + let cursor = 0; + const take = (maxLen: number): string => { + const nextCursor = Math.min(cursor + maxLen, fullLine.length); + const part = fullLine.slice(cursor, nextCursor); + cursor = nextCursor; + return part; + }; + + const prefix = take(prefixRaw.length); + const label = take(labelRaw.length); + const currentTag = take(currentTagRaw.length); + const description = take(descriptionRaw.length); + const padding = fullLine.slice(cursor); + + return { prefix, label, currentTag, description, padding }; } export function GenericSelector({ @@ -33,11 +102,13 @@ export function GenericSelector({ currentItemId, emptyMessage = 'No items available.', helpText = '↑↓ to navigate · Enter to select · Esc/Ctrl+C to cancel', + showBorder = true, }: GenericSelectorProps): React.ReactElement { const [internalIndex, setInternalIndex] = useState(0); const isControlled = controlledIndex !== undefined && onNavigate !== undefined; const selectedIndex = isControlled ? controlledIndex : internalIndex; + const lineWidth = getSelectorLineWidth(); useInput((_input, key) => { if (key.upArrow) { @@ -63,32 +134,35 @@ export function GenericSelector({ useCancelInput(() => onCancel?.(), { isActive: !!onCancel }); if (items.length === 0) { + if (showBorder) { + return ( + + {emptyMessage} + + ); + } + return ( - + {emptyMessage} ); } - return ( - - - {title} - + const content = ( + <> + {title && ( + + {title} + + )} {items.map((item, index) => { const isSelected = index === selectedIndex; @@ -98,21 +172,38 @@ export function GenericSelector({ return {renderItem(item, isSelected)}; } + const lineParts = splitDefaultSelectorLine(item, { + isSelected, + isCurrent, + width: lineWidth, + }); + return ( - {isSelected ? '› ' : ' '} - - {item.label} - - {isCurrent && (current)} - {item.description && {item.description}} + {lineParts.prefix} + {lineParts.label} + {lineParts.currentTag} + {lineParts.description} + {lineParts.padding} ); })} - - {helpText} - + {helpText ? ( + + {helpText} + + ) : null} + + ); + + if (!showBorder) { + return {content}; + } + + return ( + + {content} ); } diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index bdf53bc..0928954 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -1,40 +1,159 @@ /** * Install preparation UI component. * - * Orchestrates the preparation steps before SDK installation: - * 1. Check project config - * 2. Firebase setup (if needed) - * 3. iOS setup (if needed) - * 4. Ready for agent execution + * Uses a single task orchestrator to enforce required setup order + * before SDK installation. * * @module ui/components/InstallPreparationUI */ -import { Box, Text } from 'ink'; +import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; -import type { FirebaseSetupResult } from '@/lib/services/firebase'; -import { formatProjectType } from '@/lib/services/project-detector'; -import { useCancelInput } from '@/ui/hooks'; +import type { ApnsStatus, IosStatus, PreparationContext } from '@/commands/skill/preparation'; +import { gatherPreparationContext, saveSetupStatus } from '@/commands/skill/preparation'; +import { openBrowser } from '@/lib/auth/browser'; +import type { ProjectType } from '@/lib/config'; import { - type FirebaseStatus, - gatherPreparationContext, - type PreparationContext, - saveSetupStatus, -} from '../../commands/skill/preparation'; -import { FirebaseWizard } from './FirebaseWizard'; + analyzeIosProject, + createExtensionFiles, + type ExtensionContext, + type ExtensionGeneratorResult, + generateAppGroupId, + getEntitlementsPath, + getExtensionBundleId, + getExtensionName, + getIosProjectDir, + verifyExtensionFiles, +} from '@/lib/ios'; +import type { PushSetupContext } from '@/lib/push'; +import { + type AndroidApp, + FIREBASE_HELP_URLS, + type FirebaseDetectionResult, + FirebaseDownloader, + type FirebaseProject, + FirebaseService, + type GcpProject, + type IosApp, + isOAuthConfigured, + type ServiceAccountJson, +} from '@/lib/services/firebase'; +import { detectProjectType, formatProjectType } from '@/lib/services/project-detector'; +import { isCtrlCInput, useCancelInput } from '@/ui/hooks'; +import { + FirebaseConfigAddingFirebaseTask, + FirebaseConfigAppSelectorTask, + FirebaseConfigAuthenticatingTask, + FirebaseConfigCreateAppInputTask, + FirebaseConfigCreatingAppTask, + FirebaseConfigDetectingTask, + FirebaseConfigDownloadingTask, + FirebaseConfigErrorTask, + FirebaseConfigGcpProjectSelectorTask, + type NoAppsContext as FirebaseConfigNoAppsContext, + FirebaseConfigNoAppsFoundTask, + FirebaseConfigNoProjectsTask, + FirebaseConfigProjectSelectorTask, + FirebaseConfigStatusTask, + platformNeedsAndroidWithUnknown as firebaseConfigNeedsAndroid, + platformNeedsIosWithUnknown as firebaseConfigNeedsIos, + hasValidFirebaseConfigFiles as hasValidFirebaseConfigTaskFiles, +} from './FirebaseConfigFilesSetup'; +import { + FirebaseServiceAccountCheckingSenderConfigTask, + FirebaseServiceAccountDetectingTask, + FirebaseServiceAccountErrorTask, + FirebaseServiceAccountPasteTask, + FirebaseServiceAccountRegisteredTask, + FirebaseServiceAccountRegisteringTask, + FirebaseServiceAccountRegistrationFailedTask, + FirebaseServiceAccountSavingTask, + getProjectIdFromResult as getFirebaseProjectIdFromConfig, + hasValidFirebaseConfigFiles as hasValidServiceAccountConfigFiles, +} from './FirebaseServiceAccountSetup'; import { GenericSelector, type SelectorItem } from './GenericSelector'; +import { + getApplicableInstallTasks, + getNextIncompleteTaskId, + INSTALL_TASK_LABELS, + type InstallTaskId, + isTaskCompleted, +} from './install-preparation-tasks'; +import { + IosEntitlementsTask, + type IosEntitlementsTaskResult, +} from './ios-setup/IosEntitlementsTask'; +import { + NotificationExtensionBuildSettingsTask, + NotificationExtensionCompleteTask, + NotificationExtensionDependenciesTask, + type NotificationExtensionSetupContext, + NotificationExtensionVerificationTask, + NotificationExtensionXcodeTask, +} from './notification-extension-setup/NotificationExtensionTasks'; +import { + type ApnsKeyAcquisitionResult, + ApnsKeyAcquisitionTask, + type DetectionResult, + FirebaseApnsRegistrationTask, + FirebaseProjectSelectionTask, + PushDetectionTask, +} from './push-setup/PushSetupTasks'; + +type PreparationPhase = 'checking' | 'config_missing' | 'status' | 'task' | 'cancelled'; -type PreparationPhase = - | 'checking' - | 'config_missing' - | 'firebase_check' - | 'firebase_setup' - | 'ios_check' - | 'ios_setup' - | 'ready' - | 'cancelled'; +type InstallLeafTaskId = + | 'firebase_config_detecting' + | 'firebase_config_status' + | 'firebase_config_authenticating' + | 'firebase_config_select_project' + | 'firebase_config_select_android_app' + | 'firebase_config_select_ios_app' + | 'firebase_config_downloading' + | 'firebase_config_no_apps_found' + | 'firebase_config_create_android_app' + | 'firebase_config_create_ios_app' + | 'firebase_config_creating_app' + | 'firebase_config_no_projects' + | 'firebase_config_select_gcp_project' + | 'firebase_config_adding_firebase' + | 'firebase_config_error' + | 'apns_detecting' + | 'apns_input' + | 'apns_select_firebase_project' + | 'apns_registering' + | 'firebase_service_account_detecting' + | 'firebase_service_account_checking_sender_config' + | 'firebase_service_account_registered' + | 'firebase_service_account_input' + | 'firebase_service_account_saving' + | 'firebase_service_account_registering' + | 'firebase_service_account_registration_failed' + | 'firebase_service_account_error' + | 'ios_entitlements_run' + | 'nse_prepare_context' + | 'nse_create_files' + | 'nse_xcode_target' + | 'nse_build_settings' + | 'nse_dependencies' + | 'nse_verification' + | 'nse_complete'; + +type MissingDisplayMode = 'full' | 'summary' | 'hidden'; + +interface StatusLayoutPolicy { + showOuterSpacing: boolean; + showStatusSpacing: boolean; + showProjectType: boolean; + showDetailText: boolean; + missingDisplayMode: MissingDisplayMode; +} + +const FALLBACK_TERMINAL_ROWS = 24; +const COMPACT_ROWS_THRESHOLD = 33; +const MINIMAL_ROWS_THRESHOLD = 28; interface InstallPreparationUIProps { projectPath?: string; @@ -43,27 +162,194 @@ interface InstallPreparationUIProps { } interface ActionItem extends SelectorItem { - action: 'configure' | 'skip' | 'cancel'; + action: 'continue' | 'cancel'; } -export function getInitialPreparationPhase(context: PreparationContext): PreparationPhase { - if (!context.firebase.configured && context.firebase.needed) { - return 'firebase_check'; +type StatusLineState = 'pending' | 'checking' | 'ok' | 'missing' | 'skipped'; + +interface StatusRow { + label: string; + status: StatusLineState; + detail?: string; +} + +interface IosGuidedContextCache { + bundleId: string; + appGroupId: string; + appName: string; + iosDir: string; + entitlementsPath: string; +} + +interface TaskCompletionPatch { + ios?: Partial; + apns?: Partial; +} + +interface FirebaseConfigState { + detection: FirebaseDetectionResult | null; + projectType: ProjectType | null; + service: FirebaseService | null; + projects: FirebaseProject[]; + selectedProject: FirebaseProject | null; + androidApps: AndroidApp[]; + iosApps: IosApp[]; + selectedAndroidApp: AndroidApp | null; + downloadingPlatform: 'android' | 'ios' | 'both'; + noAppsContext: FirebaseConfigNoAppsContext | null; + creatingAppPlatform: 'android' | 'ios'; + gcpProjects: GcpProject[]; + selectedGcpProject: GcpProject | null; + error: string | null; +} + +interface FirebaseServiceAccountState { + detection: FirebaseDetectionResult | null; + projectId: string | null; + senderConfigUpdatedAt: string | null; + serviceAccountJson: ServiceAccountJson | null; + registrationError: string | null; + error: string | null; + errorNextLeafTaskId: InstallLeafTaskId; +} + +interface ApnsState { + detection: DetectionResult; + acquisition: ApnsKeyAcquisitionResult | null; + selectedProject: FirebaseProject | null; +} + +interface NotificationExtensionState { + context: NotificationExtensionSetupContext | null; + extensionResult: ExtensionGeneratorResult | null; + error: string | null; +} + +async function buildNotificationExtensionContext( + projectPath: string, + iosBundleId: string | undefined, + iosAppGroupId: string | undefined, + cachedContext: IosGuidedContextCache | null, +): Promise { + if (cachedContext) { + return cachedContext; } - if (!context.ios.entitlementsConfigured && context.ios.needed) { - return 'ios_check'; + + const analysis = await analyzeIosProject(projectPath); + if (!analysis.success || !analysis.project) { + throw new Error(analysis.error || 'Failed to analyze iOS project'); } - if (context.ready) { - return 'ready'; + + const bundleId = iosBundleId || analysis.project.bundleId; + const iosDir = getIosProjectDir(projectPath); + if (!iosDir) { + throw new Error('iOS directory not found'); + } + + const targetName = analysis.project.targets[0] || analysis.project.appName; + const entitlementsPath = + analysis.project.entitlementsFiles[0] || getEntitlementsPath(iosDir, targetName); + const appGroupId = iosAppGroupId || generateAppGroupId(bundleId); + + return { + bundleId, + appGroupId, + appName: analysis.project.appName, + iosDir, + entitlementsPath, + }; +} + +function normalizeTerminalRows(rows = process.stdout.rows): number { + if (typeof rows !== 'number' || !Number.isFinite(rows) || rows <= 0) { + return FALLBACK_TERMINAL_ROWS; + } + return rows; +} + +export function getStatusLayoutPolicy(rows = process.stdout.rows): StatusLayoutPolicy { + const terminalRows = normalizeTerminalRows(rows); + + if (terminalRows <= MINIMAL_ROWS_THRESHOLD) { + return { + showOuterSpacing: false, + showStatusSpacing: false, + showProjectType: false, + showDetailText: false, + missingDisplayMode: 'hidden', + }; + } + + if (terminalRows <= COMPACT_ROWS_THRESHOLD) { + return { + showOuterSpacing: false, + showStatusSpacing: false, + showProjectType: true, + showDetailText: true, + missingDisplayMode: 'summary', + }; + } + + return { + showOuterSpacing: true, + showStatusSpacing: true, + showProjectType: true, + showDetailText: true, + missingDisplayMode: 'full', + }; +} + +function getTaskDetail( + context: PreparationContext, + taskId: InstallTaskId, + showDetailText: boolean, +): string | undefined { + if (!showDetailText) { + return undefined; + } + + switch (taskId) { + case 'firebase_config_files': + return context.firebase.androidConfigured && context.firebase.iosConfigured + ? context.firebase.projectId || 'configured' + : 'not configured'; + case 'apns_key_for_firebase': + return context.apns.registeredWithFirebase ? context.apns.keyId || 'configured' : 'not set'; + case 'firebase_service_account': + return context.firebase.senderConfigConfigured ? 'registered' : 'not set'; + case 'ios_entitlements': + return context.ios.entitlementsConfigured ? 'configured' : 'not configured'; + case 'notification_service_extension': + return context.ios.nseConfigured ? 'configured' : 'not configured'; + default: + return undefined; } - return 'firebase_check'; } -export function getPostFirebasePhase(context: PreparationContext): PreparationPhase { - if (context.ios.needed && !context.ios.entitlementsConfigured) { - return 'ios_setup'; +function getStatusRows(context: PreparationContext, layoutPolicy: StatusLayoutPolicy): StatusRow[] { + const rows: StatusRow[] = [ + { + label: 'Project linked', + status: 'ok', + detail: layoutPolicy.showDetailText ? context.config.project.name : undefined, + }, + ]; + + if (layoutPolicy.showProjectType) { + rows.push({ + label: 'Project type', + status: context.projectType.framework !== 'unknown' ? 'ok' : 'missing', + detail: layoutPolicy.showDetailText ? formatProjectType(context.projectType) : undefined, + }); } - return 'ready'; + + const taskRows: StatusRow[] = getApplicableInstallTasks(context).map((taskId) => ({ + label: INSTALL_TASK_LABELS[taskId], + status: isTaskCompleted(context, taskId) ? 'ok' : 'missing', + detail: getTaskDetail(context, taskId, layoutPolicy.showDetailText), + })); + + return [...rows, ...taskRows]; } function StatusLine({ @@ -72,7 +358,7 @@ function StatusLine({ detail, }: { label: string; - status: 'pending' | 'checking' | 'ok' | 'missing' | 'skipped'; + status: StatusLineState; detail?: string; }): React.ReactElement { const icon = @@ -138,18 +424,27 @@ function ConfigMissingPhase({ onCancel }: { onCancel: () => void }): React.React function StatusPhase({ context, + note, onContinue, onCancel, }: { context: PreparationContext; + note: string | null; onContinue: () => void; onCancel: () => void; }): React.ReactElement { + const layoutPolicy = getStatusLayoutPolicy(); + const statusRows = getStatusRows(context, layoutPolicy); + const showMissingSummary = + layoutPolicy.missingDisplayMode === 'summary' && context.missing.length > 0; + const showMissingList = layoutPolicy.missingDisplayMode === 'full' && context.missing.length > 0; + const primaryActionLabel = context.ready ? 'Continue to installation' : 'Continue required setup'; + const items: ActionItem[] = [ { id: 'continue', - label: context.ready ? 'Continue to installation' : 'Continue anyway (some setup missing)', - action: 'configure', + label: primaryActionLabel, + action: 'continue', }, { id: 'cancel', label: 'Cancel', action: 'cancel' }, ]; @@ -166,41 +461,15 @@ function StatusPhase({ ); return ( - + Install Preparation - - - - {context.firebase.needed && ( - - )} - {context.ios.needed && ( - <> - - - - )} + + {statusRows.map((row) => ( + + ))} - {context.missing.length > 0 && ( + {showMissingList && ( Missing setup: {context.missing.map((item) => ( @@ -211,112 +480,138 @@ function StatusPhase({ )} + {showMissingSummary && ( + + + Missing setup: {context.missing.length} item{context.missing.length === 1 ? '' : 's'} + + + )} + + {note ? ( + + {note} + + ) : null} + ); } -function FirebaseSetupPhase({ - context, - onComplete, - onCancel, -}: { - context: PreparationContext; - onComplete: (result: FirebaseSetupResult) => void; - onCancel: () => void; -}): React.ReactElement { +function TaskHeader({ title, subtitle }: { title: string; subtitle: string }): React.ReactElement { return ( - - Firebase Setup - - Firebase configuration is required for push notifications. - - + + {title} + {subtitle} ); } -function ReadyPhase({ - context, - onContinue, +function GuidedStepContainer({ + onNext, onCancel, + children, }: { - context: PreparationContext; - onContinue: () => void; + onNext: () => void; onCancel: () => void; + children: React.ReactNode; }): React.ReactElement { - const items: ActionItem[] = [ - { id: 'continue', label: 'Continue to installation', action: 'configure' }, - { id: 'cancel', label: 'Cancel', action: 'cancel' }, - ]; + useInput((input, key) => { + if (key.return || input === ' ') { + onNext(); + return; + } - const handleSelect = useCallback( - (item: ActionItem) => { - if (item.action === 'cancel') { - onCancel(); - } else { - onContinue(); - } - }, - [onCancel, onContinue], - ); + if (key.escape || isCtrlCInput(input, key)) { + onCancel(); + } + }); - return ( - - - ✓ Preparation review complete - - - Project: {context.config.project.name} - Type: {formatProjectType(context.projectType)} - {context.firebase.projectId && Firebase: {context.firebase.projectId}} - - {context.missing.length > 0 && ( - - Will be handled during installation: - {context.missing.map((item) => ( - - • {item} - - ))} - - )} - - - ); + return {children}; } -function IosSetupAutoPhase({ onContinue }: { onContinue: () => void }): React.ReactElement { - useEffect(() => { - onContinue(); - }, [onContinue]); +function getInitialLeafTaskId(taskId: InstallTaskId): InstallLeafTaskId { + switch (taskId) { + case 'firebase_config_files': + return 'firebase_config_detecting'; + case 'apns_key_for_firebase': + return 'apns_detecting'; + case 'firebase_service_account': + return 'firebase_service_account_detecting'; + case 'ios_entitlements': + return 'ios_entitlements_run'; + case 'notification_service_extension': + return 'nse_prepare_context'; + default: + return 'firebase_config_detecting'; + } +} +function createInitialFirebaseConfigState(): FirebaseConfigState { + return { + detection: null, + projectType: null, + service: null, + projects: [], + selectedProject: null, + androidApps: [], + iosApps: [], + selectedAndroidApp: null, + downloadingPlatform: 'both', + noAppsContext: null, + creatingAppPlatform: 'android', + gcpProjects: [], + selectedGcpProject: null, + error: null, + }; +} + +function createInitialFirebaseServiceAccountState(): FirebaseServiceAccountState { + return { + detection: null, + projectId: null, + senderConfigUpdatedAt: null, + serviceAccountJson: null, + registrationError: null, + error: null, + errorNextLeafTaskId: 'firebase_service_account_detecting', + }; +} + +function createInitialApnsState(context: PreparationContext): ApnsState { + return { + detection: { + firebaseProjectId: context.firebase.projectId ?? null, + bundleId: context.ios.bundleId ?? null, + teamId: context.ios.teamId ?? null, + }, + acquisition: null, + selectedProject: null, + }; +} + +function createInitialNotificationExtensionState(): NotificationExtensionState { + return { + context: null, + extensionResult: null, + error: null, + }; +} + +function isFirebaseScopeError(error: string): boolean { return ( - - - ○ - iOS setup will continue during installation. - - - - - - Proceeding to final confirmation... - - + error.includes('ACCESS_TOKEN_SCOPE_INSUFFICIENT') || + error.includes('insufficient authentication scopes') || + (error.includes('403') && error.includes('PERMISSION_DENIED')) || + error.includes('invalid_grant') ); } /** * Install preparation UI component. - * - * Guides user through setup steps before SDK installation. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Central orchestration for install preparation leaf tasks. export function InstallPreparationUI({ projectPath = process.cwd(), onComplete, @@ -324,158 +619,1592 @@ export function InstallPreparationUI({ }: InstallPreparationUIProps): React.ReactElement { const [phase, setPhase] = useState('checking'); const [context, setContext] = useState(null); + const [activeTaskId, setActiveTaskId] = useState(null); + const [activeLeafTaskId, setActiveLeafTaskId] = useState(null); + const [note, setNote] = useState(null); + const [guidedContextCache, setGuidedContextCache] = useState(null); + const [firebaseConfigState, setFirebaseConfigState] = useState( + createInitialFirebaseConfigState(), + ); + const [firebaseServiceAccountState, setFirebaseServiceAccountState] = + useState(createInitialFirebaseServiceAccountState()); + const [apnsState, setApnsState] = useState(null); + const [notificationExtensionState, setNotificationExtensionState] = + useState(createInitialNotificationExtensionState()); + + const [firebaseConfigDownloader] = useState(() => new FirebaseDownloader()); + const [serviceAccountDownloader] = useState(() => new FirebaseDownloader()); + + const resetTaskRuntimeState = useCallback((preparationContext?: PreparationContext) => { + setActiveLeafTaskId(null); + setFirebaseConfigState(createInitialFirebaseConfigState()); + setFirebaseServiceAccountState(createInitialFirebaseServiceAccountState()); + setNotificationExtensionState(createInitialNotificationExtensionState()); + setApnsState(preparationContext ? createInitialApnsState(preparationContext) : null); + }, []); + + const loadPreparationContext = useCallback(async (): Promise => { + const nextContext = await gatherPreparationContext(projectPath); + if (!nextContext) { + setContext(null); + setPhase('config_missing'); + return null; + } + + setContext(nextContext); + return nextContext; + }, [projectPath]); - // Gather preparation context on mount useEffect(() => { let mounted = true; - async function check() { - const ctx = await gatherPreparationContext(projectPath); - - if (!mounted) return; + const check = async () => { + const nextContext = await gatherPreparationContext(projectPath); + if (!mounted) { + return; + } - if (!ctx) { + if (!nextContext) { setPhase('config_missing'); return; } - setContext(ctx); - setPhase(getInitialPreparationPhase(ctx)); - } + setContext(nextContext); + setPhase('status'); + }; - check(); + void check(); return () => { mounted = false; }; }, [projectPath]); - const handleFirebaseComplete = useCallback( - async (result: FirebaseSetupResult) => { - if (!context) return; - - // Extract status from detection result - const detection = result.detection; - const androidConfigured = detection?.android?.valid ?? context.firebase.androidConfigured; - const iosConfigured = detection?.ios?.valid ?? context.firebase.iosConfigured; - - // Extract project ID from detection - let projectId = context.firebase.projectId; - if (detection?.android?.content && 'project_info' in detection.android.content) { - projectId = detection.android.content.project_info?.project_id; - } else if (detection?.ios?.content && 'PROJECT_ID' in detection.ios.content) { - projectId = detection.ios.content.PROJECT_ID; + const applyTaskCompletion = useCallback( + async (patch?: TaskCompletionPatch) => { + const latest = await gatherPreparationContext(projectPath); + if (!latest) { + setContext(null); + setActiveTaskId(null); + resetTaskRuntimeState(); + setPhase('config_missing'); + return; } - // Update Firebase status based on result - const updatedFirebase: FirebaseStatus = { - ...context.firebase, - configured: result.completed && androidConfigured && iosConfigured, - androidConfigured, - iosConfigured, - projectId, - }; + const mergedIos = patch?.ios ? { ...latest.ios, ...patch.ios } : latest.ios; + const mergedApns = patch?.apns ? { ...latest.apns, ...patch.apns } : latest.apns; - const updatedContext: PreparationContext = { - ...context, - firebase: updatedFirebase, - missing: context.missing.filter((m) => !m.includes('Firebase')), - ready: - updatedFirebase.configured && - (!context.ios.needed || - (context.ios.entitlementsConfigured && context.ios.nseConfigured)), - }; + await saveSetupStatus(projectPath, latest.firebase, mergedIos, mergedApns); - setContext(updatedContext); - - // Save setup status - await saveSetupStatus(projectPath, updatedFirebase, context.ios); + const refreshed = await loadPreparationContext(); + if (!refreshed) { + return; + } - // Move to next phase - setPhase(getPostFirebasePhase(updatedContext)); + setActiveTaskId(null); + resetTaskRuntimeState(refreshed); + setNote(null); + setPhase('status'); }, - [context, projectPath], + [loadPreparationContext, projectPath, resetTaskRuntimeState], ); - const handleIosAutoContinue = useCallback(() => { - setPhase('ready'); - }, []); + const handleTaskBackToStatus = useCallback( + (nextNote?: string) => { + setActiveTaskId(null); + resetTaskRuntimeState(context ?? undefined); + setNote(nextNote ?? 'Setup step was not completed. Continue required setup to proceed.'); + setPhase('status'); + }, + [context, resetTaskRuntimeState], + ); const handleContinue = useCallback(() => { - if (!context) return; + if (!context) { + return; + } - if (phase === 'firebase_check') { - if (!context.firebase.configured && context.firebase.needed) { - setPhase('firebase_setup'); - } else if (context.ios.needed && !context.ios.entitlementsConfigured) { - setPhase('ios_setup'); - } else { - setPhase('ready'); - } - } else if (phase === 'ios_check') { - if (!context.ios.entitlementsConfigured && context.ios.needed) { - setPhase('ios_setup'); - } else { - setPhase('ready'); - } - } else if (phase === 'ready') { + if (context.ready) { onComplete(context); + return; } - }, [context, phase, onComplete]); - const handleCancel = useCallback(() => { + const nextTaskId = getNextIncompleteTaskId(context); + if (!nextTaskId) { + setNote('Unable to determine the next required setup. Try again.'); + return; + } + + setNote(null); + setActiveTaskId(nextTaskId); + setActiveLeafTaskId(getInitialLeafTaskId(nextTaskId)); + setFirebaseConfigState(createInitialFirebaseConfigState()); + setFirebaseServiceAccountState(createInitialFirebaseServiceAccountState()); + setNotificationExtensionState(createInitialNotificationExtensionState()); + setApnsState(createInitialApnsState(context)); + setPhase('task'); + }, [context, onComplete]); + + const handleCancelPreparation = useCallback(() => { setPhase('cancelled'); onCancel(); }, [onCancel]); - // Render based on phase - switch (phase) { - case 'checking': - return ; + const getFirebaseConfigNeeds = useCallback((detection: FirebaseDetectionResult | null) => { + const platform = detection?.platform ?? 'unknown'; + const needsAndroid = firebaseConfigNeedsAndroid(platform); + const needsIos = firebaseConfigNeedsIos(platform); + const needsAndroidConfig = needsAndroid && !detection?.android?.valid; + const needsIosConfig = needsIos && !detection?.ios?.valid; + const forceDownload = platform === 'unknown'; + + return { + shouldFetchAndroid: needsAndroidConfig || (forceDownload && needsAndroid), + shouldFetchIos: needsIosConfig || (forceDownload && needsIos), + }; + }, []); + + const startFirebaseConfigDownload = useCallback( + async (project: FirebaseProject, androidApp: AndroidApp | null, iosApp: IosApp | null) => { + const projectType = firebaseConfigState.projectType; + if (!projectType) { + setFirebaseConfigState((prev) => ({ + ...prev, + error: 'Project type not detected for Firebase download.', + })); + setActiveLeafTaskId('firebase_config_error'); + return; + } + + const downloadingPlatform: 'android' | 'ios' | 'both' = + androidApp && iosApp ? 'both' : androidApp ? 'android' : 'ios'; + setFirebaseConfigState((prev) => ({ ...prev, downloadingPlatform })); + setActiveLeafTaskId('firebase_config_downloading'); + + try { + const paths = firebaseConfigDownloader.getExpectedSavePaths(projectPath, projectType); + + if (androidApp && paths.android) { + await firebaseConfigDownloader.downloadAndroidConfig( + project.projectId, + androidApp.appId, + paths.android, + ); + } + + if (iosApp && paths.ios) { + await firebaseConfigDownloader.downloadIosConfig( + project.projectId, + iosApp.appId, + paths.ios, + ); + } + + const service = + firebaseConfigState.service ?? new FirebaseService(projectPath, projectType); + const newDetection = await service.detect(); + setFirebaseConfigState((prev) => ({ ...prev, service, detection: newDetection })); + + if (hasValidFirebaseConfigTaskFiles(newDetection)) { + await applyTaskCompletion(); + return; + } + + setFirebaseConfigState((prev) => ({ + ...prev, + error: 'Downloaded files could not be validated. Verify files and retry.', + })); + setActiveLeafTaskId('firebase_config_error'); + } catch (err) { + setFirebaseConfigState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Download failed', + })); + setActiveLeafTaskId('firebase_config_error'); + } + }, + [ + applyTaskCompletion, + firebaseConfigDownloader, + firebaseConfigState.projectType, + firebaseConfigState.service, + projectPath, + ], + ); + + const handleFirebaseConfigProjectSelect = useCallback( + async (project: FirebaseProject) => { + setFirebaseConfigState((prev) => ({ ...prev, selectedProject: project, error: null })); + + const { shouldFetchAndroid, shouldFetchIos } = getFirebaseConfigNeeds( + firebaseConfigState.detection, + ); + if (!shouldFetchAndroid && !shouldFetchIos) { + await applyTaskCompletion(); + return; + } + + let selectedAndroidApp: AndroidApp | null = null; + let selectedIosApp: IosApp | null = null; + let noAndroidApps = false; + let noIosApps = false; + + try { + if (shouldFetchAndroid) { + const androidApps = await firebaseConfigDownloader.listAndroidApps(project.projectId); + if (androidApps.length === 0) { + noAndroidApps = true; + setFirebaseConfigState((prev) => ({ + ...prev, + androidApps: [], + selectedAndroidApp: null, + })); + } else if (androidApps.length > 1) { + setFirebaseConfigState((prev) => ({ ...prev, androidApps, selectedAndroidApp: null })); + setActiveLeafTaskId('firebase_config_select_android_app'); + return; + } else { + selectedAndroidApp = androidApps[0]; + setFirebaseConfigState((prev) => ({ + ...prev, + androidApps, + selectedAndroidApp: selectedAndroidApp, + })); + } + } + + if (shouldFetchIos) { + const iosApps = await firebaseConfigDownloader.listIosApps(project.projectId); + if (iosApps.length === 0) { + noIosApps = true; + setFirebaseConfigState((prev) => ({ ...prev, iosApps })); + } else if (iosApps.length > 1) { + setFirebaseConfigState((prev) => ({ ...prev, iosApps })); + setActiveLeafTaskId('firebase_config_select_ios_app'); + return; + } else { + selectedIosApp = iosApps[0]; + setFirebaseConfigState((prev) => ({ ...prev, iosApps })); + } + } + + if ((shouldFetchAndroid && noAndroidApps) || (shouldFetchIos && noIosApps)) { + setFirebaseConfigState((prev) => ({ + ...prev, + noAppsContext: { + noAndroidApps, + noIosApps, + needsAndroid: shouldFetchAndroid, + needsIos: shouldFetchIos, + }, + })); + setActiveLeafTaskId('firebase_config_no_apps_found'); + return; + } + + await startFirebaseConfigDownload(project, selectedAndroidApp, selectedIosApp); + } catch (err) { + setFirebaseConfigState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to fetch Firebase apps', + })); + setActiveLeafTaskId('firebase_config_error'); + } + }, + [ + applyTaskCompletion, + firebaseConfigDownloader, + firebaseConfigState.detection, + getFirebaseConfigNeeds, + startFirebaseConfigDownload, + ], + ); + + const handleFirebaseConfigAndroidAppSelect = useCallback( + async (app: AndroidApp) => { + const project = firebaseConfigState.selectedProject; + if (!project) { + setFirebaseConfigState((prev) => ({ ...prev, error: 'No Firebase project selected.' })); + setActiveLeafTaskId('firebase_config_error'); + return; + } + + setFirebaseConfigState((prev) => ({ ...prev, selectedAndroidApp: app })); + const { shouldFetchIos } = getFirebaseConfigNeeds(firebaseConfigState.detection); + + if (!shouldFetchIos) { + await startFirebaseConfigDownload(project, app, null); + return; + } + + try { + const iosApps = await firebaseConfigDownloader.listIosApps(project.projectId); + setFirebaseConfigState((prev) => ({ ...prev, iosApps })); + + if (iosApps.length === 0) { + await startFirebaseConfigDownload(project, app, null); + return; + } + + if (iosApps.length === 1) { + await startFirebaseConfigDownload(project, app, iosApps[0]); + return; + } + + setActiveLeafTaskId('firebase_config_select_ios_app'); + } catch { + await startFirebaseConfigDownload(project, app, null); + } + }, + [ + firebaseConfigDownloader, + firebaseConfigState.detection, + firebaseConfigState.selectedProject, + getFirebaseConfigNeeds, + startFirebaseConfigDownload, + ], + ); + + const handleFirebaseConfigCreateApp = useCallback( + async (platform: 'android' | 'ios', identifier: string, displayName?: string) => { + const selectedProject = firebaseConfigState.selectedProject; + if (!selectedProject) { + setFirebaseConfigState((prev) => ({ ...prev, error: 'No Firebase project selected.' })); + setActiveLeafTaskId('firebase_config_error'); + return; + } + + setFirebaseConfigState((prev) => ({ ...prev, creatingAppPlatform: platform })); + setActiveLeafTaskId('firebase_config_creating_app'); + + try { + if (platform === 'android') { + const androidApp = await firebaseConfigDownloader.createAndroidApp( + selectedProject.projectId, + { + packageName: identifier, + displayName, + }, + ); + + setFirebaseConfigState((prev) => ({ + ...prev, + androidApps: [androidApp], + selectedAndroidApp: androidApp, + })); + + const { shouldFetchIos } = getFirebaseConfigNeeds(firebaseConfigState.detection); + if (!shouldFetchIos) { + await startFirebaseConfigDownload(selectedProject, androidApp, null); + return; + } + + const iosApps = await firebaseConfigDownloader.listIosApps(selectedProject.projectId); + setFirebaseConfigState((prev) => ({ ...prev, iosApps })); + + if (iosApps.length === 0) { + setFirebaseConfigState((prev) => ({ + ...prev, + noAppsContext: { + noAndroidApps: false, + noIosApps: true, + needsAndroid: false, + needsIos: true, + }, + })); + setActiveLeafTaskId('firebase_config_no_apps_found'); + return; + } + + if (iosApps.length === 1) { + await startFirebaseConfigDownload(selectedProject, androidApp, iosApps[0]); + return; + } + + setActiveLeafTaskId('firebase_config_select_ios_app'); + return; + } + + const iosApp = await firebaseConfigDownloader.createIosApp(selectedProject.projectId, { + bundleId: identifier, + displayName, + }); + + await startFirebaseConfigDownload( + selectedProject, + firebaseConfigState.selectedAndroidApp, + iosApp, + ); + } catch (err) { + setFirebaseConfigState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to create Firebase app', + })); + setActiveLeafTaskId('firebase_config_error'); + } + }, + [ + firebaseConfigDownloader, + firebaseConfigState.detection, + firebaseConfigState.selectedAndroidApp, + firebaseConfigState.selectedProject, + getFirebaseConfigNeeds, + startFirebaseConfigDownload, + ], + ); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_config_files' || + activeLeafTaskId !== 'firebase_config_detecting' + ) { + return; + } + + let cancelled = false; + + const detect = async () => { + try { + const detectedProjectType = context?.projectType ?? (await detectProjectType(projectPath)); + const service = new FirebaseService(projectPath, detectedProjectType); + const detection = await service.detect(); + + if (cancelled) { + return; + } + + setFirebaseConfigState((prev) => ({ + ...prev, + projectType: detectedProjectType, + service, + detection, + error: null, + })); + + if (hasValidFirebaseConfigTaskFiles(detection)) { + await applyTaskCompletion(); + return; + } + + setActiveLeafTaskId('firebase_config_status'); + } catch (err) { + if (cancelled) { + return; + } + + setFirebaseConfigState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to detect Firebase config', + })); + setActiveLeafTaskId('firebase_config_error'); + } + }; + + void detect(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + applyTaskCompletion, + context?.projectType, + phase, + projectPath, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_config_files' || + activeLeafTaskId !== 'firebase_config_authenticating' + ) { + return; + } + + let cancelled = false; - case 'config_missing': - return ; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Step branching mirrors Firebase setup decision tree. + const authenticate = async () => { + try { + if (!isOAuthConfigured()) { + throw new Error('Google OAuth is not configured. Set Firebase OAuth credentials first.'); + } - case 'firebase_check': - if (!context) return ; - return ; + const isAuthenticated = await firebaseConfigDownloader.isAuthenticated(); + if (!isAuthenticated) { + const authResult = await firebaseConfigDownloader.authenticate(openBrowser); + if (!authResult.success) { + throw new Error(authResult.error || 'Firebase authentication failed.'); + } + } + + const projects = await firebaseConfigDownloader.listProjects(); + if (cancelled) { + return; + } + + if (projects.length === 0) { + setActiveLeafTaskId('firebase_config_no_projects'); + return; + } + + setFirebaseConfigState((prev) => ({ ...prev, projects })); + if (projects.length === 1) { + await handleFirebaseConfigProjectSelect(projects[0]); + return; + } + + setActiveLeafTaskId('firebase_config_select_project'); + } catch (err) { + if (cancelled) { + return; + } + + setFirebaseConfigState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to authenticate Firebase', + })); + setActiveLeafTaskId('firebase_config_error'); + } + }; + + void authenticate(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + firebaseConfigDownloader, + handleFirebaseConfigProjectSelect, + phase, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_config_files' || + activeLeafTaskId !== 'firebase_config_select_gcp_project' + ) { + return; + } + + if (firebaseConfigState.gcpProjects.length > 0) { + return; + } + + let cancelled = false; + + const fetchGcpProjects = async () => { + try { + const available = await firebaseConfigDownloader.listAvailableGcpProjects(); + if (cancelled) { + return; + } + + if (available.length === 0) { + throw new Error('No GCP projects available to add Firebase.'); + } + + setFirebaseConfigState((prev) => ({ ...prev, gcpProjects: available })); + } catch (err) { + if (cancelled) { + return; + } + + setFirebaseConfigState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to fetch GCP projects', + })); + setActiveLeafTaskId('firebase_config_error'); + } + }; + + void fetchGcpProjects(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + firebaseConfigDownloader, + firebaseConfigState.gcpProjects.length, + phase, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_config_files' || + activeLeafTaskId !== 'firebase_config_adding_firebase' + ) { + return; + } + + if (!firebaseConfigState.selectedGcpProject) { + setFirebaseConfigState((prev) => ({ ...prev, error: 'No GCP project selected.' })); + setActiveLeafTaskId('firebase_config_error'); + return; + } + + let cancelled = false; + + const addFirebase = async () => { + try { + const project = await firebaseConfigDownloader.addFirebaseToProject( + firebaseConfigState.selectedGcpProject?.projectId || '', + ); + + if (cancelled) { + return; + } + + setFirebaseConfigState((prev) => ({ + ...prev, + projects: [project], + selectedProject: project, + })); + await handleFirebaseConfigProjectSelect(project); + } catch (err) { + if (cancelled) { + return; + } + + setFirebaseConfigState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to add Firebase to GCP project', + })); + setActiveLeafTaskId('firebase_config_error'); + } + }; + + void addFirebase(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + firebaseConfigDownloader, + firebaseConfigState.selectedGcpProject, + handleFirebaseConfigProjectSelect, + phase, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_service_account' || + activeLeafTaskId !== 'firebase_service_account_detecting' + ) { + return; + } + + let cancelled = false; + + const detect = async () => { + try { + const detectedProjectType = context?.projectType ?? (await detectProjectType(projectPath)); + const firebaseService = new FirebaseService(projectPath, detectedProjectType); + const detection = await firebaseService.detect(); + + if (cancelled) { + return; + } + + setFirebaseServiceAccountState((prev) => ({ ...prev, detection, error: null })); + + if (!hasValidServiceAccountConfigFiles(detection)) { + setFirebaseServiceAccountState((prev) => ({ + ...prev, + error: 'Firebase Configuration Files are missing. Complete that step first.', + errorNextLeafTaskId: 'firebase_service_account_detecting', + })); + setActiveLeafTaskId('firebase_service_account_error'); + return; + } + + const projectId = getFirebaseProjectIdFromConfig(detection); + if (!projectId) { + setFirebaseServiceAccountState((prev) => ({ + ...prev, + error: 'Unable to determine Firebase Project ID from config files.', + errorNextLeafTaskId: 'firebase_service_account_detecting', + })); + setActiveLeafTaskId('firebase_service_account_error'); + return; + } + + setFirebaseServiceAccountState((prev) => ({ + ...prev, + projectId, + error: null, + })); + setActiveLeafTaskId( + context?.config.project.id + ? 'firebase_service_account_checking_sender_config' + : 'firebase_service_account_input', + ); + } catch (err) { + if (cancelled) { + return; + } + + setFirebaseServiceAccountState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to detect Firebase config.', + errorNextLeafTaskId: 'firebase_service_account_detecting', + })); + setActiveLeafTaskId('firebase_service_account_error'); + } + }; + + void detect(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + context?.config.project.id, + context?.projectType, + phase, + projectPath, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_service_account' || + activeLeafTaskId !== 'firebase_service_account_checking_sender_config' + ) { + return; + } + + const clixProjectId = context?.config.project.id; + if (!clixProjectId) { + setActiveLeafTaskId('firebase_service_account_input'); + return; + } + + let cancelled = false; + + const checkSenderConfig = async () => { + try { + const { getInternalApiClient } = await import('@/lib/api'); + const project = await getInternalApiClient().getProject(clixProjectId); + const pushConfig = project.sender_configs?.find( + (config) => config.channel_type === 'CHANNEL_TYPE_APP_PUSH', + ); + + if (cancelled) { + return; + } + + if (pushConfig) { + setFirebaseServiceAccountState((prev) => ({ + ...prev, + senderConfigUpdatedAt: pushConfig.updated_at ?? pushConfig.created_at ?? null, + })); + setActiveLeafTaskId('firebase_service_account_registered'); + return; + } + + setActiveLeafTaskId('firebase_service_account_input'); + } catch { + if (cancelled) { + return; + } + + setActiveLeafTaskId('firebase_service_account_input'); + } + }; + + void checkSenderConfig(); + + return () => { + cancelled = true; + }; + }, [activeLeafTaskId, activeTaskId, context?.config.project.id, phase]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_service_account' || + activeLeafTaskId !== 'firebase_service_account_saving' + ) { + return; + } + + const json = firebaseServiceAccountState.serviceAccountJson; + if (!json) { + setFirebaseServiceAccountState((prev) => ({ + ...prev, + error: 'Service account JSON is missing. Paste it again.', + errorNextLeafTaskId: 'firebase_service_account_input', + })); + setActiveLeafTaskId('firebase_service_account_error'); + return; + } + + let cancelled = false; + + const saveServiceAccountJson = async () => { + try { + await serviceAccountDownloader.saveServiceAccountJson(projectPath, json); + if (cancelled) { + return; + } + + if (!context?.config.project.id) { + await applyTaskCompletion(); + return; + } + + setActiveLeafTaskId('firebase_service_account_registering'); + } catch (err) { + if (cancelled) { + return; + } + + setFirebaseServiceAccountState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to save service account JSON.', + errorNextLeafTaskId: 'firebase_service_account_input', + })); + setActiveLeafTaskId('firebase_service_account_error'); + } + }; + + void saveServiceAccountJson(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + applyTaskCompletion, + context?.config.project.id, + firebaseServiceAccountState.serviceAccountJson, + phase, + projectPath, + serviceAccountDownloader, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_service_account' || + activeLeafTaskId !== 'firebase_service_account_registering' + ) { + return; + } + + const clixProjectId = context?.config.project.id; + if (!clixProjectId) { + void applyTaskCompletion(); + return; + } + + let cancelled = false; + + const registerSenderConfig = async () => { + try { + const { getInternalApiClient } = await import('@/lib/api'); + const apiClient = getInternalApiClient(); + const serviceAccount = + firebaseServiceAccountState.serviceAccountJson ?? + (await serviceAccountDownloader.loadServiceAccountJson(projectPath)); + + if (!serviceAccount) { + throw new Error('Service account JSON not found. Import the key again and retry.'); + } + + const encoded = Buffer.from(JSON.stringify(serviceAccount), 'utf-8').toString('base64'); + await apiClient.createOrUpdateSenderConfig(clixProjectId, { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { fcm_sa_json_base64_encoded: encoded }, + android_config: { fcm_sa_json_base64_encoded: encoded }, + }, + }); + + if (cancelled) { + return; + } + + await applyTaskCompletion(); + } catch (err) { + if (cancelled) { + return; + } + + setFirebaseServiceAccountState((prev) => ({ + ...prev, + registrationError: + err instanceof Error ? err.message : 'Unknown error while registering sender config', + })); + setActiveLeafTaskId('firebase_service_account_registration_failed'); + } + }; + + void registerSenderConfig(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + applyTaskCompletion, + context?.config.project.id, + firebaseServiceAccountState.serviceAccountJson, + phase, + projectPath, + serviceAccountDownloader, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'notification_service_extension' || + activeLeafTaskId !== 'nse_prepare_context' + ) { + return; + } + + let cancelled = false; + + const prepareContext = async () => { + try { + const nextContext = await buildNotificationExtensionContext( + projectPath, + context?.ios.bundleId, + context?.ios.appGroupId, + guidedContextCache, + ); + + if (cancelled) { + return; + } + + setGuidedContextCache(nextContext); + setNotificationExtensionState((prev) => ({ ...prev, context: nextContext, error: null })); + setActiveLeafTaskId('nse_create_files'); + } catch (err) { + if (cancelled) { + return; + } + + handleTaskBackToStatus(err instanceof Error ? err.message : 'Failed to prepare NSE setup'); + } + }; + + void prepareContext(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + context?.ios.appGroupId, + context?.ios.bundleId, + guidedContextCache, + handleTaskBackToStatus, + phase, + projectPath, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'notification_service_extension' || + activeLeafTaskId !== 'nse_create_files' + ) { + return; + } + + const nseContext = notificationExtensionState.context; + if (!nseContext) { + handleTaskBackToStatus('Notification extension context is missing.'); + return; + } + + let cancelled = false; + + const createFiles = async () => { + const extensionContext: ExtensionContext = { + appName: nseContext.appName, + bundleId: nseContext.bundleId, + iosDir: nseContext.iosDir, + }; + + const result = await createExtensionFiles(extensionContext); + if (cancelled) { + return; + } + + setNotificationExtensionState((prev) => ({ ...prev, extensionResult: result })); + if (!result.success) { + setNotificationExtensionState((prev) => ({ + ...prev, + error: result.error || 'Failed to create extension files', + })); + setActiveLeafTaskId('nse_complete'); + return; + } + + setActiveLeafTaskId('nse_xcode_target'); + }; + + void createFiles(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + handleTaskBackToStatus, + notificationExtensionState.context, + phase, + ]); + + if (phase === 'checking') { + return ; + } + + if (phase === 'config_missing') { + return ; + } + + if (phase === 'cancelled') { + return ( + + Installation cancelled. + + ); + } + + if (!context) { + return ; + } + + if (phase === 'status' || !activeTaskId || !activeLeafTaskId) { + return ( + + ); + } + + switch (activeTaskId) { + case 'firebase_config_files': { + const detection = firebaseConfigState.detection; + const selectedProject = firebaseConfigState.selectedProject; - case 'firebase_setup': - if (!context) return ; return ( - + + + + {activeLeafTaskId === 'firebase_config_detecting' && } + + {activeLeafTaskId === 'firebase_config_status' && detection && ( + setActiveLeafTaskId('firebase_config_authenticating')} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'firebase_config_authenticating' && ( + handleTaskBackToStatus()} /> + )} + + {activeLeafTaskId === 'firebase_config_select_project' && ( + { + void handleFirebaseConfigProjectSelect(project); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'firebase_config_select_android_app' && ( + { + void handleFirebaseConfigAndroidAppSelect(app as AndroidApp); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'firebase_config_select_ios_app' && ( + { + if (!selectedProject) { + handleTaskBackToStatus('No Firebase project selected.'); + return; + } + + void startFirebaseConfigDownload( + selectedProject, + firebaseConfigState.selectedAndroidApp, + app as IosApp, + ); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'firebase_config_no_apps_found' && ( + setActiveLeafTaskId('firebase_config_create_android_app')} + onCreateIos={() => setActiveLeafTaskId('firebase_config_create_ios_app')} + onCancel={() => setActiveLeafTaskId('firebase_config_select_project')} + /> + )} + + {activeLeafTaskId === 'firebase_config_create_android_app' && ( + { + void handleFirebaseConfigCreateApp('android', identifier, displayName); + }} + onCancel={() => setActiveLeafTaskId('firebase_config_no_apps_found')} + /> + )} + + {activeLeafTaskId === 'firebase_config_create_ios_app' && ( + { + void handleFirebaseConfigCreateApp('ios', identifier, displayName); + }} + onCancel={() => setActiveLeafTaskId('firebase_config_no_apps_found')} + /> + )} + + {activeLeafTaskId === 'firebase_config_creating_app' && ( + + )} + + {activeLeafTaskId === 'firebase_config_no_projects' && ( + { + openBrowser(FIREBASE_HELP_URLS.console); + setActiveLeafTaskId('firebase_config_detecting'); + }} + onSelectGcp={() => { + setFirebaseConfigState((prev) => ({ ...prev, gcpProjects: [] })); + setActiveLeafTaskId('firebase_config_select_gcp_project'); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'firebase_config_select_gcp_project' && + (firebaseConfigState.gcpProjects.length > 0 ? ( + { + setFirebaseConfigState((prev) => ({ ...prev, selectedGcpProject: project })); + setActiveLeafTaskId('firebase_config_adding_firebase'); + }} + onCancel={() => setActiveLeafTaskId('firebase_config_no_projects')} + /> + ) : ( + handleTaskBackToStatus()} /> + ))} + + {activeLeafTaskId === 'firebase_config_adding_firebase' && ( + + )} + + {activeLeafTaskId === 'firebase_config_downloading' && ( + + )} + + {activeLeafTaskId === 'firebase_config_error' && ( + { + const error = firebaseConfigState.error || ''; + setFirebaseConfigState((prev) => ({ ...prev, error: null })); + + if (isFirebaseScopeError(error)) { + void firebaseConfigDownloader.logout(); + setActiveLeafTaskId('firebase_config_authenticating'); + return; + } + + setActiveLeafTaskId('firebase_config_detecting'); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + ); + } + + case 'apns_key_for_firebase': { + const currentApnsState = apnsState ?? createInitialApnsState(context); + const registrationContext: PushSetupContext = { + bundleId: currentApnsState.detection.bundleId, + firebaseProjectId: currentApnsState.detection.firebaseProjectId, + pushKey: currentApnsState.acquisition?.pushKey ?? null, + p8FilePath: currentApnsState.acquisition?.p8FilePath ?? null, + }; + + return ( + + + + {activeLeafTaskId === 'apns_detecting' && ( + { + setApnsState((prev) => ({ + ...(prev || createInitialApnsState(context)), + detection: result, + })); + setActiveLeafTaskId('apns_input'); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} - case 'ios_check': - if (!context) return ; - return ; + {activeLeafTaskId === 'apns_input' && ( + { + setApnsState((prev) => ({ + ...(prev || createInitialApnsState(context)), + acquisition: result, + })); - case 'ios_setup': - if (!context) return ; - return ; + setActiveLeafTaskId( + isOAuthConfigured() ? 'apns_select_firebase_project' : 'apns_registering', + ); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'apns_select_firebase_project' && ( + { + setApnsState((prev) => ({ + ...(prev || createInitialApnsState(context)), + selectedProject: project, + })); + setActiveLeafTaskId('apns_registering'); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'apns_registering' && + (registrationContext.pushKey ? ( + { + void applyTaskCompletion({ + apns: { + needed: context.apns.needed, + registeredWithFirebase: true, + keyId: registrationContext.pushKey?.apnsKeyId, + teamId: registrationContext.pushKey?.teamId, + }, + }); + }} + onCancel={() => handleTaskBackToStatus()} + /> + ) : ( + + ✗ APNS key data is missing. Retry this step. + + ))} + + ); + } - case 'ready': - if (!context) return ; + case 'firebase_service_account': return ( - onComplete(context)} - onCancel={handleCancel} - /> + + + + {activeLeafTaskId === 'firebase_service_account_detecting' && ( + + )} + + {activeLeafTaskId === 'firebase_service_account_checking_sender_config' && ( + + )} + + {activeLeafTaskId === 'firebase_service_account_registered' && ( + { + void applyTaskCompletion(); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'firebase_service_account_input' && ( + { + setFirebaseServiceAccountState((prev) => ({ + ...prev, + serviceAccountJson: json, + registrationError: null, + error: null, + })); + setActiveLeafTaskId('firebase_service_account_saving'); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'firebase_service_account_saving' && ( + + )} + + {activeLeafTaskId === 'firebase_service_account_registering' && ( + + )} + + {activeLeafTaskId === 'firebase_service_account_registration_failed' && ( + setActiveLeafTaskId('firebase_service_account_input')} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {activeLeafTaskId === 'firebase_service_account_error' && ( + { + setFirebaseServiceAccountState((prev) => ({ ...prev, error: null })); + setActiveLeafTaskId(firebaseServiceAccountState.errorNextLeafTaskId); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + ); + + case 'ios_entitlements': + return ( + + + { + if (!result.success) { + handleTaskBackToStatus(result.error || undefined); + return; + } + + if (result.agentContext) { + setGuidedContextCache({ + bundleId: result.agentContext.bundleId, + appGroupId: result.agentContext.appGroupId, + appName: result.agentContext.appName, + iosDir: result.agentContext.iosDir, + entitlementsPath: result.agentContext.entitlementsPath, + }); + } + + void applyTaskCompletion({ + ios: { + needed: context.ios.needed, + bundleId: result.bundleId ?? context.ios.bundleId, + teamId: result.teamId ?? context.ios.teamId, + appGroupId: result.agentContext?.appGroupId ?? context.ios.appGroupId, + entitlementsConfigured: true, + }, + }); + }} + /> + ); - case 'cancelled': + case 'notification_service_extension': { + const nseContext = notificationExtensionState.context; + const extensionResult = notificationExtensionState.extensionResult; + const extensionName = nseContext ? getExtensionName(nseContext.appName) : ''; + const extensionBundleId = nseContext + ? getExtensionBundleId(nseContext.bundleId, nseContext.appName) + : ''; + return ( - - Installation cancelled. + + + + {activeLeafTaskId === 'nse_prepare_context' && ( + + + + + + Preparing extension setup context... + + + )} + + {activeLeafTaskId === 'nse_create_files' && ( + + + + + + Creating Notification Service Extension files... + + + )} + + {activeLeafTaskId === 'nse_xcode_target' && nseContext && ( + setActiveLeafTaskId('nse_build_settings')} + onCancel={() => handleTaskBackToStatus()} + > + + + )} + + {activeLeafTaskId === 'nse_build_settings' && nseContext && ( + setActiveLeafTaskId('nse_dependencies')} + onCancel={() => handleTaskBackToStatus()} + > + + + )} + + {activeLeafTaskId === 'nse_dependencies' && ( + setActiveLeafTaskId('nse_verification')} + onCancel={() => handleTaskBackToStatus()} + > + + + )} + + {activeLeafTaskId === 'nse_verification' && nseContext && ( + { + const verification = verifyExtensionFiles(nseContext.iosDir, nseContext.appName); + if (!verification.complete) { + handleTaskBackToStatus('Notification Service Extension files are still missing.'); + return; + } + setActiveLeafTaskId('nse_complete'); + }} + onCancel={() => handleTaskBackToStatus()} + > + + + )} + + {activeLeafTaskId === 'nse_complete' && ( + { + if (notificationExtensionState.error) { + handleTaskBackToStatus(notificationExtensionState.error); + return; + } + void applyTaskCompletion({ + ios: { + needed: context.ios.needed, + nseConfigured: true, + }, + }); + }} + onCancel={() => handleTaskBackToStatus()} + > + + + )} ); + } default: - return ; + return ( + + ); } } diff --git a/src/ui/components/IosSetupFlow.tsx b/src/ui/components/IosSetupFlow.tsx deleted file mode 100644 index 8126c0a..0000000 --- a/src/ui/components/IosSetupFlow.tsx +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Integrated iOS Setup Flow for Interactive mode. - * Combines Phase 1 (IosSetupUI), Phase 2 (GuidedSetupWizard), and Phase 3 (PushSetupWizard). - */ -import { Box, Text, useInput } from 'ink'; -import type React from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import type { PushSetupResult } from '../../lib/push'; -import { isCtrlCInput, useCancelInput } from '../hooks'; -import { type IosSetupResult, IosSetupUI } from '../IosSetupUI'; -import { - type GuidedSetupContext, - type GuidedSetupResult, - GuidedSetupWizard, -} from './GuidedSetupWizard'; -import { PushSetupWizard } from './PushSetupWizard'; - -type FlowPhase = - | 'intro' // Welcome screen - | 'direct_setup' // Phase 1: IosSetupUI - | 'guided_setup' // Phase 2: GuidedSetupWizard - | 'push_confirm' // Confirm push setup - | 'push_setup' // Phase 3: PushSetupWizard - | 'complete'; // All done - -export interface IosSetupFlowResult { - success: boolean; - message: string; - directResult?: IosSetupResult; - guidedResult?: GuidedSetupResult; - pushResult?: PushSetupResult | null; -} - -interface IosSetupFlowProps { - onComplete: (result: IosSetupFlowResult) => void; -} - -/** - * Intro screen component - */ -const IntroScreen: React.FC<{ - onContinue: () => void; - onCancel: () => void; -}> = ({ onContinue, onCancel }) => { - useInput((_input, key) => { - if (key.return) { - onContinue(); - } - }); - - useCancelInput(onCancel); - - return ( - - - iOS Push Notification Setup - - - This wizard will configure your iOS app for push notifications: - - - 1. Configure entitlements and capabilities - 2. Create Notification Service Extension files - 3. Set up APNS key for Firebase (optional) - - - Press Enter to continue, Esc/Ctrl+C to cancel - - - ); -}; - -/** - * Push setup confirmation component - */ -const PushSetupConfirmation: React.FC<{ - onYes: () => void; - onNo: () => void; -}> = ({ onYes, onNo }) => { - useInput((input, key) => { - if (input.toLowerCase() === 'y' || key.return) { - onYes(); - } else if (input.toLowerCase() === 'n' || key.escape || isCtrlCInput(input, key)) { - onNo(); - } - }); - - return ( - - - ✓ iOS setup completed! - - - - Set up APNS key for Firebase push notifications? [Y/n] - - - - ); -}; - -/** - * Integrated iOS Setup Flow component - */ -export const IosSetupFlow: React.FC = ({ onComplete }) => { - const [phase, setPhase] = useState('intro'); - const [directResult, setDirectResult] = useState(null); - const [guidedResult, setGuidedResult] = useState(null); - - // Intro handlers - const handleIntroContinue = useCallback(() => { - setPhase('direct_setup'); - }, []); - - const handleIntroCancel = useCallback(() => { - onComplete({ - success: false, - message: 'iOS setup cancelled', - }); - }, [onComplete]); - - // Phase 1 complete handler - const handleDirectSetupComplete = useCallback( - (result: IosSetupResult) => { - setDirectResult(result); - - if (!result.success) { - onComplete({ - success: false, - message: result.error || 'iOS setup failed', - directResult: result, - }); - return; - } - - // If there's agent context, proceed to guided setup - if (result.agentContext) { - setPhase('guided_setup'); - } else { - // No extension needed, go to push confirmation - setPhase('push_confirm'); - } - }, - [onComplete], - ); - - // Phase 2 complete handler - const handleGuidedSetupComplete = useCallback((result: GuidedSetupResult) => { - setGuidedResult(result); - // Always proceed to push confirmation (even if guided setup had issues) - setPhase('push_confirm'); - }, []); - - // Push confirmation handlers - const handlePushConfirmYes = useCallback(() => { - setPhase('push_setup'); - }, []); - - const handlePushConfirmNo = useCallback(() => { - // Complete without push setup - const message = buildCompletionMessage(directResult, guidedResult, null); - onComplete({ - success: true, - message, - directResult: directResult ?? undefined, - guidedResult: guidedResult ?? undefined, - pushResult: null, - }); - }, [directResult, guidedResult, onComplete]); - - // Phase 3 complete handler - const handlePushSetupComplete = useCallback( - (result: PushSetupResult) => { - const message = buildCompletionMessage(directResult, guidedResult, result); - onComplete({ - success: true, - message, - directResult: directResult ?? undefined, - guidedResult: guidedResult ?? undefined, - pushResult: result, - }); - }, - [directResult, guidedResult, onComplete], - ); - - // Push setup cancel handler - const handlePushSetupCancel = useCallback(() => { - const message = buildCompletionMessage(directResult, guidedResult, null); - onComplete({ - success: true, - message, - directResult: directResult ?? undefined, - guidedResult: guidedResult ?? undefined, - pushResult: null, - }); - }, [directResult, guidedResult, onComplete]); - - // Build guided setup context from direct result - const getGuidedSetupContext = (): GuidedSetupContext | null => { - if (!directResult?.agentContext) return null; - return { - bundleId: directResult.agentContext.bundleId, - appGroupId: directResult.agentContext.appGroupId, - appName: directResult.agentContext.appName, - iosDir: directResult.agentContext.iosDir, - entitlementsPath: directResult.agentContext.entitlementsPath, - }; - }; - - // Handle edge case where guided_setup has no context (avoid setState during render) - useEffect(() => { - if (phase === 'guided_setup' && !directResult?.agentContext) { - setPhase('push_confirm'); - } - }, [phase, directResult?.agentContext]); - - // Render based on current phase - switch (phase) { - case 'intro': - return ; - - case 'direct_setup': - return ; - - case 'guided_setup': { - const context = getGuidedSetupContext(); - if (!context) { - // Will be handled by useEffect above - return null; - } - return ; - } - - case 'push_confirm': - return ; - - case 'push_setup': - return ( - - ); - - case 'complete': - return null; - - default: - return null; - } -}; - -/** - * Build completion message summarizing all phases - */ -function buildCompletionMessage( - directResult: IosSetupResult | null, - guidedResult: GuidedSetupResult | null, - pushResult: PushSetupResult | null, -): string { - const lines: string[] = ['iOS Push Setup Complete!', '']; - - if (directResult?.success) { - lines.push('✓ Capabilities configured'); - lines.push('✓ Entitlements created'); - } - - if (guidedResult?.success) { - lines.push('✓ Extension files created'); - } - - if (pushResult?.success) { - lines.push('✓ APNS key registered with Firebase'); - } else if (pushResult === null) { - lines.push('○ APNS key setup skipped'); - } - - lines.push(''); - lines.push('Your iOS app is ready to receive push notifications!'); - - return lines.join('\n'); -} diff --git a/src/ui/components/ProjectSelector.tsx b/src/ui/components/ProjectSelector.tsx index da39f08..60be9bf 100644 --- a/src/ui/components/ProjectSelector.tsx +++ b/src/ui/components/ProjectSelector.tsx @@ -14,6 +14,12 @@ export interface FlattenedProject { } const PROJECT_SORTER: Intl.Collator = new Intl.Collator(undefined, { sensitivity: 'base' }); +const FALLBACK_TERMINAL_ROWS = 24; +const COMPACT_TERMINAL_ROWS = 34; +const MEDIUM_TERMINAL_ROWS = 42; +const COMPACT_MAX_VISIBLE_ITEMS = 5; +const MEDIUM_MAX_VISIBLE_ITEMS = 7; +const LARGE_MAX_VISIBLE_ITEMS = 10; export interface ProjectSelectorProps { organizations: OrgWithProjects[]; @@ -61,6 +67,19 @@ export function filterProjectsByQuery( return projects.filter((item) => item.project.name.toLowerCase().includes(normalizedQuery)); } +export function getProjectSelectorMaxVisible(rows = process.stdout.rows): number { + const terminalRows = + typeof rows === 'number' && Number.isFinite(rows) && rows > 0 ? rows : FALLBACK_TERMINAL_ROWS; + + if (terminalRows <= COMPACT_TERMINAL_ROWS) { + return COMPACT_MAX_VISIBLE_ITEMS; + } + if (terminalRows <= MEDIUM_TERMINAL_ROWS) { + return MEDIUM_MAX_VISIBLE_ITEMS; + } + return LARGE_MAX_VISIBLE_ITEMS; +} + function applySearchInput( input: string, key: { backspace?: boolean; delete?: boolean; space?: boolean }, @@ -96,8 +115,8 @@ export const ProjectSelector: React.FC = ({ ); const [selectedIndex, setSelectedIndex] = useState(0); - // Calculate visible window for scrolling (show max 10 items) - const maxVisible = 10; + // Calculate visible window for scrolling based on terminal height. + const maxVisible = getProjectSelectorMaxVisible(); const totalItems = filteredProjects.length; const halfWindow = Math.floor(maxVisible / 2); diff --git a/src/ui/components/__tests__/file-input-utils.test.ts b/src/ui/components/__tests__/file-input-utils.test.ts new file mode 100644 index 0000000..08e8609 --- /dev/null +++ b/src/ui/components/__tests__/file-input-utils.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + normalizeInputFilePath, + readTextFileFromInputPath, + resolveInputFilePath, +} from '../file-input-utils'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.map(async (tempDir) => { + await fs.rm(tempDir, { recursive: true, force: true }); + }), + ); + tempDirs.length = 0; +}); + +describe('file-input-utils', () => { + test('normalizes quoted and escaped drag-and-drop paths', () => { + const raw = '"/tmp/My\\ File.p8"'; + expect(normalizeInputFilePath(raw)).toBe('/tmp/My File.p8'); + }); + + test('resolves relative paths to absolute paths', () => { + const relative = './AuthKey_TEST123456.p8'; + expect(resolveInputFilePath(relative)).toBe(path.resolve(relative)); + }); + + test('reads text file from drag-and-drop style input path', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-input-utils-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'AuthKey_TEST123456.p8'); + await fs.writeFile(filePath, 'test-content', 'utf-8'); + + const draggedPath = `"${filePath.replace(/ /g, '\\ ')}"`; + const content = await readTextFileFromInputPath(draggedPath); + expect(content).toBe('test-content'); + }); +}); diff --git a/src/ui/components/__tests__/firebase-wizard-transitions.test.ts b/src/ui/components/__tests__/firebase-wizard-transitions.test.ts deleted file mode 100644 index 92d9f23..0000000 --- a/src/ui/components/__tests__/firebase-wizard-transitions.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { - type ExtendedWizardPhase, - PHASE_TRANSITIONS, - transition, -} from '../firebase-wizard-transitions'; - -describe('PHASE_TRANSITIONS', () => { - test('all target phases are valid ExtendedWizardPhase values', () => { - // Collect all phases that appear as targets - const targetPhases = new Set(); - for (const events of Object.values(PHASE_TRANSITIONS)) { - if (events) { - for (const target of Object.values(events)) { - targetPhases.add(target); - } - } - } - - // Collect all phases that appear as sources (keys in PHASE_TRANSITIONS) - // plus 'complete' which is a terminal state - const sourcePhases = new Set(Object.keys(PHASE_TRANSITIONS)); - sourcePhases.add('complete'); - - // Every target phase must be either a source phase or 'complete' - for (const target of targetPhases) { - expect(sourcePhases.has(target)).toBe(true); - } - }); - - test('error phase has retry transition', () => { - expect(PHASE_TRANSITIONS.error?.retry).toBe('detecting'); - }); - - test('complete phase has no transitions', () => { - expect(PHASE_TRANSITIONS.complete).toBeUndefined(); - }); -}); - -describe('transition()', () => { - test('returns correct target phase for valid transitions', () => { - expect(transition('detecting', 'success')).toBe('status'); - expect(transition('detecting', 'error')).toBe('error'); - expect(transition('status', 'continue')).toBe('menu'); - expect(transition('menu', 'done')).toBe('complete'); - }); - - test('throws on invalid event', () => { - expect(() => transition('detecting', 'nonexistent')).toThrow( - 'Invalid phase transition: detecting + "nonexistent"', - ); - }); - - test('throws on phase with no transitions', () => { - expect(() => transition('complete' as ExtendedWizardPhase, 'anything')).toThrow( - 'Invalid phase transition: complete + "anything"', - ); - }); - - test('error message includes valid events', () => { - try { - transition('detecting', 'bad'); - expect(true).toBe(false); // Should not reach - } catch (err) { - expect((err as Error).message).toContain('Valid events: success, error'); - } - }); -}); - -describe('flow: download → sender config → SA → complete', () => { - test('happy path: download → check sender config (not registered) → SA setup → register → complete', () => { - // Download completes - expect(transition('downloading', 'success')).toBe('checking_sender_config'); - // Sender config not registered - expect(transition('checking_sender_config', 'not_registered')).toBe('service_account_menu'); - // User pastes SA JSON - expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); - // Save SA JSON - expect(transition('paste_service_account', 'save')).toBe('saving_service_account'); - // Register sender config (has clixProjectId) - expect(transition('saving_service_account', 'register')).toBe('registering_sender_config'); - // Registration completes - expect(transition('registering_sender_config', 'complete')).toBe('complete'); - }); - - test('sender config already registered → complete', () => { - expect(transition('downloading', 'success')).toBe('checking_sender_config'); - expect(transition('checking_sender_config', 'registered')).toBe('sender_config_registered'); - expect(transition('sender_config_registered', 'continue')).toBe('complete'); - }); - - test('no clixProjectId → skip sender config check → SA setup → complete without register', () => { - expect(transition('downloading', 'success')).toBe('checking_sender_config'); - // Skip because no clixProjectId - expect(transition('checking_sender_config', 'skip')).toBe('service_account_menu'); - expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); - expect(transition('paste_service_account', 'save')).toBe('saving_service_account'); - // No clixProjectId → complete directly - expect(transition('saving_service_account', 'complete')).toBe('complete'); - }); - - test('sender config API error → still goes to SA menu', () => { - expect(transition('checking_sender_config', 'error')).toBe('service_account_menu'); - }); -}); - -describe('flow: initial detection → menu → done', () => { - test('detect → status → menu → done', () => { - expect(transition('detecting', 'success')).toBe('status'); - expect(transition('status', 'continue')).toBe('menu'); - expect(transition('menu', 'done')).toBe('complete'); - }); - - test('detect → status → menu → skip', () => { - expect(transition('detecting', 'success')).toBe('status'); - expect(transition('status', 'continue')).toBe('menu'); - expect(transition('menu', 'skip')).toBe('complete'); - }); - - test('detect error → retry', () => { - expect(transition('detecting', 'error')).toBe('error'); - expect(transition('error', 'retry')).toBe('detecting'); - }); -}); - -describe('flow: download authentication', () => { - test('auth → select project → select apps → download', () => { - expect(transition('authenticating', 'select_project')).toBe('select_project'); - expect(transition('select_project', 'select_android_app')).toBe('select_android_app'); - expect(transition('select_android_app', 'select_ios_app')).toBe('select_ios_app'); - // select_ios_app triggers download via ref (not a phase transition) - }); - - test('auth → no projects → open console → menu', () => { - expect(transition('authenticating', 'no_projects')).toBe('no_projects'); - expect(transition('no_projects', 'open_console')).toBe('menu'); - }); - - test('auth → no projects → GCP → add firebase', () => { - expect(transition('authenticating', 'no_projects')).toBe('no_projects'); - // Note: no_projects → select_gcp is indirect (goes through authenticating again) - }); - - test('auth error', () => { - expect(transition('authenticating', 'error')).toBe('error'); - }); -}); - -describe('flow: app creation', () => { - test('no apps → create android → creating → needs iOS → no apps found again', () => { - expect(transition('no_apps_found', 'create_android')).toBe('create_android_app'); - // create_android_app submission triggers handleCreateApp directly (not via transition) - // After creating: needs iOS → go back to no_apps_found - expect(transition('creating_app', 'no_apps_found')).toBe('no_apps_found'); - }); - - test('no apps → create ios', () => { - expect(transition('no_apps_found', 'create_ios')).toBe('create_ios_app'); - }); - - test('no apps → cancel → back to project selection', () => { - expect(transition('no_apps_found', 'cancel')).toBe('select_project'); - }); - - test('app creation error', () => { - expect(transition('creating_app', 'error')).toBe('error'); - }); -}); - -describe('flow: service account menu', () => { - test('open console → paste SA', () => { - expect(transition('service_account_menu', 'open_console')).toBe('paste_service_account'); - }); - - test('paste json → paste SA', () => { - expect(transition('service_account_menu', 'paste_json')).toBe('paste_service_account'); - }); - - test('skip SA → status', () => { - expect(transition('service_account_menu', 'skip')).toBe('status'); - }); - - test('cancel paste → back to SA menu', () => { - expect(transition('paste_service_account', 'cancel')).toBe('service_account_menu'); - }); - - test('save error', () => { - expect(transition('saving_service_account', 'error')).toBe('error'); - }); -}); - -describe('flow: menu → validate', () => { - test('validate → success → status', () => { - expect(transition('menu', 'validate')).toBe('validating'); - expect(transition('validating', 'success')).toBe('status'); - }); - - test('validate → error', () => { - expect(transition('menu', 'validate')).toBe('validating'); - expect(transition('validating', 'error')).toBe('error'); - }); -}); - -describe('flow: menu → redetect', () => { - test('redetect all → detecting', () => { - expect(transition('menu', 'redetect')).toBe('detecting'); - }); - - test('redetect platform → detecting', () => { - expect(transition('menu', 'redetect_platform')).toBe('detecting'); - }); -}); - -describe('flow: GCP project', () => { - test('select GCP → add firebase', () => { - expect(transition('select_gcp_project', 'adding_firebase')).toBe('adding_firebase'); - }); - - test('select GCP error', () => { - expect(transition('select_gcp_project', 'error')).toBe('error'); - }); - - test('add firebase error', () => { - expect(transition('adding_firebase', 'error')).toBe('error'); - }); -}); diff --git a/src/ui/components/__tests__/generic-selector.test.ts b/src/ui/components/__tests__/generic-selector.test.ts new file mode 100644 index 0000000..9dca5d9 --- /dev/null +++ b/src/ui/components/__tests__/generic-selector.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test'; +import { + formatDefaultSelectorLine, + getSelectorLineWidth, + type SelectorItem, +} from '../GenericSelector'; + +describe('GenericSelector line formatting', () => { + test('pads short lines to fixed width to clear stale characters', () => { + const item: SelectorItem = { id: 'cancel', label: 'Cancel' }; + const line = formatDefaultSelectorLine(item, { + isSelected: true, + isCurrent: false, + width: 40, + }); + + expect(line.startsWith('› Cancel')).toBe(true); + expect(line.length).toBe(40); + }); + + test('does not keep trailing text from previous longer labels', () => { + const longItem: SelectorItem = { + id: 'continue', + label: 'Continue anyway (some setup missing)', + }; + const shortItem: SelectorItem = { id: 'cancel', label: 'Cancel' }; + + const previousLine = formatDefaultSelectorLine(longItem, { + isSelected: true, + isCurrent: false, + width: 44, + }); + const nextLine = formatDefaultSelectorLine(shortItem, { + isSelected: true, + isCurrent: false, + width: 44, + }); + + expect(previousLine.includes('Continue anyway')).toBe(true); + expect(nextLine.includes('Continue anyway')).toBe(false); + expect(nextLine.includes('Cancelue')).toBe(false); + expect(nextLine.length).toBe(44); + }); + + test('includes current marker and description in one stable line', () => { + const item: SelectorItem = { + id: 'alpha', + label: 'Alpha', + description: 'desc', + }; + const line = formatDefaultSelectorLine(item, { + isSelected: false, + isCurrent: true, + width: 32, + }); + + expect(line.includes('Alpha')).toBe(true); + expect(line.includes('(current)')).toBe(true); + expect(line.includes('desc')).toBe(true); + expect(line.length).toBe(32); + }); +}); + +describe('getSelectorLineWidth', () => { + test('uses terminal columns and subtracts selector chrome width', () => { + expect(getSelectorLineWidth(120)).toBe(114); + }); + + test('uses fallback when columns is undefined', () => { + expect(getSelectorLineWidth(undefined)).toBe(74); + }); + + test('enforces minimum width', () => { + expect(getSelectorLineWidth(1)).toBe(10); + }); +}); diff --git a/src/ui/components/__tests__/install-preparation-flow.test.ts b/src/ui/components/__tests__/install-preparation-flow.test.ts new file mode 100644 index 0000000..fa69701 --- /dev/null +++ b/src/ui/components/__tests__/install-preparation-flow.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from 'bun:test'; +import type { + ApnsStatus, + FirebaseStatus, + IosStatus, + PreparationContext, +} from '@/commands/skill/preparation'; +import type { ProjectType } from '@/lib/config'; +import { getStatusLayoutPolicy } from '../InstallPreparationUI'; +import { + getApplicableInstallTasks, + getNextIncompleteTaskId, + isTaskCompleted, +} from '../install-preparation-tasks'; + +function createContext( + firebase: FirebaseStatus, + ios: IosStatus, + apns: ApnsStatus, + ready: boolean, + missing: string[] = [], +): PreparationContext { + const projectType: ProjectType = { framework: 'react-native', target: 'ios-android' }; + + return { + projectPath: '/tmp/project', + config: { + version: 1, + member: { id: 'member-1', email: 'a@b.com', name: 'member' }, + organization: { id: 'org-1', name: 'org' }, + project: { id: 'project-1', name: 'project' }, + linkedAt: new Date().toISOString(), + projectType, + }, + projectType, + firebase, + ios, + apns, + ready, + missing, + }; +} + +describe('Install preparation task pipeline', () => { + test('returns all iOS-related tasks for iOS/cross-platform projects', () => { + const context = createContext( + { + configured: false, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: false, + needed: true, + }, + { + needed: true, + entitlementsConfigured: false, + nseConfigured: false, + }, + { + needed: true, + registeredWithFirebase: false, + }, + false, + ['APNS Key for Firebase'], + ); + + expect(getApplicableInstallTasks(context)).toEqual([ + 'firebase_config_files', + 'apns_key_for_firebase', + 'firebase_service_account', + 'ios_entitlements', + 'notification_service_extension', + ]); + }); + + test('returns first incomplete required task in order', () => { + const context = createContext( + { + configured: false, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: false, + needed: true, + }, + { + needed: true, + entitlementsConfigured: false, + nseConfigured: false, + }, + { + needed: true, + registeredWithFirebase: false, + }, + false, + ['APNS Key for Firebase', 'Firebase Service Account'], + ); + + expect(getNextIncompleteTaskId(context)).toBe('apns_key_for_firebase'); + }); + + test('moves to service account after APNS is complete', () => { + const context = createContext( + { + configured: false, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: false, + needed: true, + }, + { + needed: true, + entitlementsConfigured: false, + nseConfigured: false, + }, + { + needed: true, + keyId: 'ABC1234567', + registeredWithFirebase: true, + }, + false, + ['Firebase Service Account'], + ); + + expect(getNextIncompleteTaskId(context)).toBe('firebase_service_account'); + }); + + test('considers APNS task complete only when registered', () => { + const baseContext = createContext( + { + configured: false, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: false, + needed: true, + }, + { + needed: true, + entitlementsConfigured: true, + nseConfigured: false, + }, + { + needed: true, + registeredWithFirebase: false, + }, + false, + ['APNS Key for Firebase'], + ); + + expect(isTaskCompleted(baseContext, 'apns_key_for_firebase')).toBe(false); + + const completedContext = { + ...baseContext, + apns: { + ...baseContext.apns, + registeredWithFirebase: true, + }, + }; + + expect(isTaskCompleted(completedContext, 'apns_key_for_firebase')).toBe(true); + }); +}); + +describe('InstallPreparationUI status layout policy', () => { + test('uses full layout on tall terminals', () => { + const policy = getStatusLayoutPolicy(40); + expect(policy.showOuterSpacing).toBe(true); + expect(policy.showStatusSpacing).toBe(true); + expect(policy.showProjectType).toBe(true); + expect(policy.showDetailText).toBe(true); + expect(policy.missingDisplayMode).toBe('full'); + }); + + test('uses compact layout on medium-height terminals', () => { + const policy = getStatusLayoutPolicy(30); + expect(policy.showOuterSpacing).toBe(false); + expect(policy.showStatusSpacing).toBe(false); + expect(policy.showProjectType).toBe(true); + expect(policy.showDetailText).toBe(true); + expect(policy.missingDisplayMode).toBe('summary'); + }); + + test('uses minimal layout on short terminals', () => { + const policy = getStatusLayoutPolicy(24); + expect(policy.showOuterSpacing).toBe(false); + expect(policy.showStatusSpacing).toBe(false); + expect(policy.showProjectType).toBe(false); + expect(policy.showDetailText).toBe(false); + expect(policy.missingDisplayMode).toBe('hidden'); + }); + + test('falls back to default rows when terminal rows are invalid', () => { + const policy = getStatusLayoutPolicy(undefined); + expect(policy.showProjectType).toBe(false); + expect(policy.showDetailText).toBe(false); + expect(policy.missingDisplayMode).toBe('hidden'); + }); +}); diff --git a/src/ui/components/__tests__/project-selector.test.ts b/src/ui/components/__tests__/project-selector.test.ts new file mode 100644 index 0000000..2d88b53 --- /dev/null +++ b/src/ui/components/__tests__/project-selector.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test'; +import type { Organization, Project } from '@/lib/api'; +import { + filterProjectsByQuery, + getProjectSelectorMaxVisible, + getSortedProjects, + type OrgWithProjects, +} from '../ProjectSelector'; + +function createOrganization(id: string, name: string): Organization { + return { id, name }; +} + +function createProject(id: string, name: string, organizationId: string): Project { + return { id, name, organization_id: organizationId }; +} + +describe('ProjectSelector helpers', () => { + test('sorts projects by project name, then organization name, then project id', () => { + const organizations: OrgWithProjects[] = [ + { + org: createOrganization('org-2', 'Beta Org'), + projects: [ + createProject('p-2', 'zeta', 'org-2'), + createProject('p-4', 'alpha', 'org-2'), + createProject('p-6', 'Same Name', 'org-2'), + ], + }, + { + org: createOrganization('org-1', 'Alpha Org'), + projects: [ + createProject('p-1', 'Alpha', 'org-1'), + createProject('p-3', 'beta', 'org-1'), + createProject('p-5', 'Same Name', 'org-1'), + ], + }, + { + org: createOrganization('org-3', 'Alpha Org'), + projects: [createProject('p-0', 'Same Name', 'org-3')], + }, + ]; + + const sorted = getSortedProjects(organizations); + const sortedNames = sorted.map( + (item) => `${item.project.name}::${item.org.name}::${item.project.id}`, + ); + + expect(sortedNames).toEqual([ + 'Alpha::Alpha Org::p-1', + 'alpha::Beta Org::p-4', + 'beta::Alpha Org::p-3', + 'Same Name::Alpha Org::p-0', + 'Same Name::Alpha Org::p-5', + 'Same Name::Beta Org::p-6', + 'zeta::Beta Org::p-2', + ]); + }); + + test('filters by partial project name match (case-insensitive)', () => { + const organizations: OrgWithProjects[] = [ + { + org: createOrganization('org-1', 'Alpha Org'), + projects: [ + createProject('p-1', 'Marketing Mobile', 'org-1'), + createProject('p-2', 'Core SDK', 'org-1'), + createProject('p-3', 'QA Testbed', 'org-1'), + ], + }, + ]; + + const sorted = getSortedProjects(organizations); + + expect(filterProjectsByQuery(sorted, 'ket').map((item) => item.project.name)).toEqual([ + 'Marketing Mobile', + ]); + expect(filterProjectsByQuery(sorted, 'SDK').map((item) => item.project.name)).toEqual([ + 'Core SDK', + ]); + }); + + test('returns all projects when query is empty or whitespace', () => { + const organizations: OrgWithProjects[] = [ + { + org: createOrganization('org-1', 'Alpha Org'), + projects: [createProject('p-1', 'One', 'org-1'), createProject('p-2', 'Two', 'org-1')], + }, + ]; + + const sorted = getSortedProjects(organizations); + + expect(filterProjectsByQuery(sorted, '').length).toBe(2); + expect(filterProjectsByQuery(sorted, ' ').length).toBe(2); + }); + + test('uses compact visible item count for small terminals', () => { + expect(getProjectSelectorMaxVisible(30)).toBe(5); + expect(getProjectSelectorMaxVisible(34)).toBe(5); + }); + + test('uses medium visible item count for medium terminals', () => { + expect(getProjectSelectorMaxVisible(35)).toBe(7); + expect(getProjectSelectorMaxVisible(42)).toBe(7); + }); + + test('uses large visible item count for large terminals', () => { + expect(getProjectSelectorMaxVisible(43)).toBe(10); + expect(getProjectSelectorMaxVisible(60)).toBe(10); + }); +}); diff --git a/src/ui/components/file-input-utils.ts b/src/ui/components/file-input-utils.ts new file mode 100644 index 0000000..0e398e9 --- /dev/null +++ b/src/ui/components/file-input-utils.ts @@ -0,0 +1,36 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +/** + * Normalize a file path entered in terminal input. + * + * - Trim whitespace + * - Strip surrounding single/double quotes + * - Unescape escaped spaces from drag-and-drop input (\ ) + */ +export function normalizeInputFilePath(inputPath: string): string { + const trimmed = inputPath.trim(); + const withoutQuotes = trimmed.replace(/^['"]|['"]$/g, ''); + return withoutQuotes.replace(/\\ /g, ' '); +} + +/** + * Resolve input path to an absolute filesystem path. + * Supports "~" home directory expansion. + */ +export function resolveInputFilePath(inputPath: string): string { + const normalized = normalizeInputFilePath(inputPath); + const expanded = normalized.startsWith('~') + ? path.join(process.env.HOME || '', normalized.slice(1)) + : normalized; + + return path.resolve(expanded); +} + +/** + * Read UTF-8 file content from terminal input path. + */ +export async function readTextFileFromInputPath(inputPath: string): Promise { + const resolvedPath = resolveInputFilePath(inputPath); + return fs.readFile(resolvedPath, 'utf-8'); +} diff --git a/src/ui/components/firebase-wizard-transitions.ts b/src/ui/components/firebase-wizard-transitions.ts deleted file mode 100644 index fa0d3e0..0000000 --- a/src/ui/components/firebase-wizard-transitions.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { WizardPhase } from '@/lib/services/firebase'; - -/** - * Extended wizard phase for download flow. - */ -export type ExtendedWizardPhase = - | WizardPhase - | 'authenticating' - | 'select_project' - | 'select_android_app' - | 'select_ios_app' - | 'downloading' - | 'no_apps_found' - | 'create_android_app' - | 'create_ios_app' - | 'creating_app' - | 'no_projects' - | 'select_gcp_project' - | 'adding_firebase' - | 'checking_sender_config' - | 'sender_config_registered' - | 'service_account_menu' - | 'paste_service_account' - | 'saving_service_account' - | 'registering_sender_config'; - -/** - * Phase transition event type. - * Each key represents a named event that triggers a transition. - */ -type TransitionEvent = string; - -/** - * Centralized phase transition map. - * - * Every valid phase transition is defined here. This makes the entire - * wizard flow visible in one place and prevents invalid transitions. - * - * Flow overview: - * detecting → status → menu → (various actions) - * menu/download → authenticating → select_project → select_*_app → downloading - * downloading → checking_sender_config → service_account_menu → paste → saving → complete - * - * Adding a new phase: - * 1. Add the phase to ExtendedWizardPhase type - * 2. Add transition rules here - * 3. Add the phase component and rendering case - */ -export const PHASE_TRANSITIONS: Partial< - Record> -> = { - // === Initial Detection === - detecting: { - success: 'status', - error: 'error', - }, - - // === Status & Menu === - status: { - continue: 'menu', - }, - menu: { - redetect: 'detecting', - redetect_platform: 'detecting', - validate: 'validating', - download: 'authenticating', - setup_service_account: 'service_account_menu', - done: 'complete', - skip: 'complete', - }, - validating: { - success: 'status', - error: 'error', - }, - - // === Download / Auth Flow === - authenticating: { - no_projects: 'no_projects', - select_project: 'select_project', - select_gcp_project: 'select_gcp_project', - error: 'error', - }, - select_project: { - no_apps_found: 'no_apps_found', - select_android_app: 'select_android_app', - select_ios_app: 'select_ios_app', - error: 'error', - }, - select_android_app: { - select_ios_app: 'select_ios_app', - // note: also triggers download via ref (not a phase transition) - }, - select_ios_app: { - // note: triggers download via ref (not a phase transition) - }, - downloading: { - success: 'checking_sender_config', - error: 'error', - }, - - // === No Apps / App Creation === - no_apps_found: { - create_android: 'create_android_app', - create_ios: 'create_ios_app', - cancel: 'select_project', - }, - create_android_app: { - submit: 'creating_app', - cancel: 'select_project', - }, - create_ios_app: { - submit: 'creating_app', - cancel: 'select_project', - }, - creating_app: { - no_apps_found: 'no_apps_found', - select_ios_app: 'select_ios_app', - error: 'error', - // note: also triggers download via ref (not a phase transition) - }, - - // === No Projects / GCP === - no_projects: { - open_console: 'menu', - select_gcp: 'authenticating', - }, - select_gcp_project: { - adding_firebase: 'adding_firebase', - error: 'error', - }, - adding_firebase: { - error: 'error', - // note: on success, triggers project selection via ref - }, - - // === Sender Config Check === - checking_sender_config: { - registered: 'sender_config_registered', - not_registered: 'service_account_menu', - skip: 'service_account_menu', - error: 'service_account_menu', - }, - sender_config_registered: { - continue: 'complete', - }, - - // === Service Account Setup === - service_account_menu: { - open_console: 'paste_service_account', - paste_json: 'paste_service_account', - skip: 'status', - error: 'error', - }, - paste_service_account: { - save: 'saving_service_account', - cancel: 'service_account_menu', - }, - saving_service_account: { - register: 'registering_sender_config', - complete: 'complete', - error: 'error', - }, - registering_sender_config: { - complete: 'complete', - }, - - // === Terminal States === - error: { - retry: 'detecting', - // note: retry with re-auth goes through handleDownload (not a simple transition) - }, - // 'complete' has no outgoing transitions -}; - -/** - * Perform a validated phase transition. - * - * @param from - Current phase - * @param event - Named event triggering the transition - * @returns Target phase - * @throws Error if the transition is not defined in PHASE_TRANSITIONS - */ -export function transition(from: ExtendedWizardPhase, event: TransitionEvent): ExtendedWizardPhase { - const transitions = PHASE_TRANSITIONS[from]; - const target = transitions?.[event]; - if (!target) { - throw new Error( - `Invalid phase transition: ${from} + "${event}". ` + - `Valid events: ${transitions ? Object.keys(transitions).join(', ') : 'none'}`, - ); - } - return target; -} diff --git a/src/ui/components/install-preparation-tasks.ts b/src/ui/components/install-preparation-tasks.ts new file mode 100644 index 0000000..0ce580f --- /dev/null +++ b/src/ui/components/install-preparation-tasks.ts @@ -0,0 +1,64 @@ +import type { PreparationContext } from '@/commands/skill/preparation'; + +export type InstallTaskId = + | 'firebase_config_files' + | 'apns_key_for_firebase' + | 'firebase_service_account' + | 'ios_entitlements' + | 'notification_service_extension'; + +const INSTALL_TASK_ORDER: InstallTaskId[] = [ + 'firebase_config_files', + 'apns_key_for_firebase', + 'firebase_service_account', + 'ios_entitlements', + 'notification_service_extension', +]; + +export const INSTALL_TASK_LABELS: Record = { + firebase_config_files: 'Firebase Configuration Files', + apns_key_for_firebase: 'APNS Key for Firebase', + firebase_service_account: 'Firebase Service Account', + ios_entitlements: 'iOS Entitlements', + notification_service_extension: 'Notification Service Extension', +}; + +export function isTaskApplicable(context: PreparationContext, taskId: InstallTaskId): boolean { + switch (taskId) { + case 'firebase_config_files': + case 'firebase_service_account': + return context.firebase.needed; + case 'apns_key_for_firebase': + case 'ios_entitlements': + case 'notification_service_extension': + return context.ios.needed; + default: + return false; + } +} + +export function isTaskCompleted(context: PreparationContext, taskId: InstallTaskId): boolean { + switch (taskId) { + case 'firebase_config_files': + return context.firebase.androidConfigured && context.firebase.iosConfigured; + case 'apns_key_for_firebase': + return context.apns.registeredWithFirebase; + case 'firebase_service_account': + return context.firebase.senderConfigConfigured; + case 'ios_entitlements': + return context.ios.entitlementsConfigured; + case 'notification_service_extension': + return context.ios.nseConfigured; + default: + return false; + } +} + +export function getApplicableInstallTasks(context: PreparationContext): InstallTaskId[] { + return INSTALL_TASK_ORDER.filter((taskId) => isTaskApplicable(context, taskId)); +} + +export function getNextIncompleteTaskId(context: PreparationContext): InstallTaskId | null { + const tasks = getApplicableInstallTasks(context); + return tasks.find((taskId) => !isTaskCompleted(context, taskId)) ?? null; +} diff --git a/src/ui/components/ios-setup/IosEntitlementsTask.tsx b/src/ui/components/ios-setup/IosEntitlementsTask.tsx new file mode 100644 index 0000000..d8b9c72 --- /dev/null +++ b/src/ui/components/ios-setup/IosEntitlementsTask.tsx @@ -0,0 +1,12 @@ +import type React from 'react'; +import { type IosSetupResult, IosSetupUI } from '@/ui/IosSetupUI'; + +export type IosEntitlementsTaskResult = IosSetupResult; + +interface IosEntitlementsTaskProps { + onComplete: (result: IosEntitlementsTaskResult) => void; +} + +export const IosEntitlementsTask: React.FC = ({ onComplete }) => { + return ; +}; diff --git a/src/ui/components/GuidedSetupWizard.tsx b/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx similarity index 54% rename from src/ui/components/GuidedSetupWizard.tsx rename to src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx index 8d3e942..47b9cbb 100644 --- a/src/ui/components/GuidedSetupWizard.tsx +++ b/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx @@ -1,33 +1,14 @@ /** - * Guided Setup Wizard - Replaces AI Agent for Xcode configuration - * Provides step-by-step guidance for manual Xcode tasks + * Notification Extension setup task components. */ -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import type React from 'react'; -import { useEffect, useState } from 'react'; -import { - createExtensionFiles, - type ExtensionContext, - type ExtensionGeneratorResult, - getExtensionBundleId, - getExtensionName, - verifyExtensionFiles, -} from '@/lib/ios/extension-generator'; +import { type ExtensionGeneratorResult, verifyExtensionFiles } from '@/lib/ios/extension-generator'; import { generatePodfileSnippet } from '@/lib/ios/extension-templates'; -import { isCtrlCInput } from '@/ui/hooks'; -import { Header } from './Header'; -import { StatusMessage } from './StatusMessage'; +import { StatusMessage } from '../StatusMessage'; -export type GuidedSetupPhase = - | 'creating_files' - | 'xcode_target' - | 'build_settings' - | 'dependencies' - | 'verification' - | 'complete'; - -export interface GuidedSetupContext { +export interface NotificationExtensionSetupContext { bundleId: string; appGroupId: string; appName: string; @@ -36,156 +17,7 @@ export interface GuidedSetupContext { pushEnvironment?: 'development' | 'production'; } -export interface GuidedSetupResult { - success: boolean; - extensionCreated: boolean; - extensionDir?: string; - createdFiles: string[]; - error?: string; -} - -interface GuidedSetupWizardProps { - context: GuidedSetupContext; - onComplete: (result: GuidedSetupResult) => void; -} - -interface WizardState { - phase: GuidedSetupPhase; - extensionResult: ExtensionGeneratorResult | null; - error: string | null; -} - -export const GuidedSetupWizard: React.FC = ({ context, onComplete }) => { - const [state, setState] = useState({ - phase: 'creating_files', - extensionResult: null, - error: null, - }); - - const { phase, extensionResult, error } = state; - const extensionName = getExtensionName(context.appName); - const extensionBundleId = getExtensionBundleId(context.bundleId, context.appName); - - // Create extension files on mount - useEffect(() => { - if (phase !== 'creating_files') return; - - const create = async () => { - const extContext: ExtensionContext = { - appName: context.appName, - bundleId: context.bundleId, - iosDir: context.iosDir, - pushEnvironment: context.pushEnvironment, - }; - - const result = await createExtensionFiles(extContext); - - if (!result.success) { - setState((s) => ({ - ...s, - phase: 'complete', - extensionResult: result, - error: result.error || 'Failed to create extension files', - })); - return; - } - - setState((s) => ({ - ...s, - phase: 'xcode_target', - extensionResult: result, - })); - }; - - create(); - }, [phase, context]); - - // Handle keyboard input for navigation - useInput((input, key) => { - if (key.return || input === ' ') { - switch (phase) { - case 'xcode_target': - setState((s) => ({ ...s, phase: 'build_settings' })); - break; - case 'build_settings': - setState((s) => ({ ...s, phase: 'dependencies' })); - break; - case 'dependencies': - setState((s) => ({ ...s, phase: 'verification' })); - break; - case 'verification': - setState((s) => ({ ...s, phase: 'complete' })); - break; - } - } - - const shouldCancel = key.escape || isCtrlCInput(input, key); - if (shouldCancel && phase !== 'creating_files' && phase !== 'complete') { - setState((s) => ({ ...s, phase: 'complete' })); - } - }); - - // Complete handler - useEffect(() => { - if (phase === 'complete') { - const verification = verifyExtensionFiles(context.iosDir, context.appName); - - setTimeout(() => { - onComplete({ - success: !error && verification.complete, - extensionCreated: !!extensionResult?.createdFiles.length, - extensionDir: extensionResult?.extensionDir, - createdFiles: extensionResult?.createdFiles || [], - error: error || undefined, - }); - }, 500); - } - }, [phase, error, extensionResult, context, onComplete]); - - return ( - -
- - {/* Phase: Creating Files */} - {phase === 'creating_files' && ( - - )} - - {/* Phase: Xcode Target Guide */} - {phase === 'xcode_target' && ( - - )} - - {/* Phase: Build Settings Guide */} - {phase === 'build_settings' && ( - - )} - - {/* Phase: Dependencies Guide */} - {phase === 'dependencies' && } - - {/* Phase: Verification */} - {phase === 'verification' && ( - - )} - - {/* Phase: Complete */} - {phase === 'complete' && } - - ); -}; - -// Sub-components for each phase - -const XcodeTargetGuide: React.FC<{ +export const NotificationExtensionXcodeTask: React.FC<{ extensionName: string; extensionBundleId: string; extensionResult: ExtensionGeneratorResult | null; @@ -195,9 +27,9 @@ const XcodeTargetGuide: React.FC<{ {extensionResult && extensionResult.createdFiles.length > 0 && ( - {extensionResult.createdFiles.map((file) => ( - - • {file} + {extensionResult.createdFiles.map((filePath) => ( + + • {filePath} ))} @@ -237,7 +69,7 @@ const XcodeTargetGuide: React.FC<{ ); -const BuildSettingsGuide: React.FC<{ +export const NotificationExtensionBuildSettingsTask: React.FC<{ extensionName: string; entitlementsPath: string; }> = ({ extensionName, entitlementsPath }) => ( @@ -284,7 +116,7 @@ const BuildSettingsGuide: React.FC<{ ); -const DependenciesGuide: React.FC<{ +export const NotificationExtensionDependenciesTask: React.FC<{ extensionName: string; }> = ({ extensionName }) => { const podfileSnippet = generatePodfileSnippet(extensionName); @@ -324,8 +156,8 @@ const DependenciesGuide: React.FC<{ ); }; -const VerificationPhase: React.FC<{ - context: GuidedSetupContext; +export const NotificationExtensionVerificationTask: React.FC<{ + context: NotificationExtensionSetupContext; extensionResult: ExtensionGeneratorResult | null; }> = ({ context, extensionResult }) => { const verification = verifyExtensionFiles(context.iosDir, context.appName); @@ -347,9 +179,9 @@ const VerificationPhase: React.FC<{ ) : ( - {verification.missingFiles.map((file) => ( - - • {file} + {verification.missingFiles.map((filePath) => ( + + • {filePath} ))} @@ -377,7 +209,7 @@ const VerificationPhase: React.FC<{ ); }; -const CompletePhase: React.FC<{ +export const NotificationExtensionCompleteTask: React.FC<{ error: string | null; extensionResult: ExtensionGeneratorResult | null; }> = ({ error, extensionResult }) => ( @@ -394,9 +226,9 @@ const CompletePhase: React.FC<{ {extensionResult && extensionResult.createdFiles.length > 0 && ( Created files: - {extensionResult.createdFiles.map((file) => ( - - • {file} + {extensionResult.createdFiles.map((filePath) => ( + + • {filePath} ))} diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/push-setup/PushSetupTasks.tsx similarity index 62% rename from src/ui/components/PushSetupWizard.tsx rename to src/ui/components/push-setup/PushSetupTasks.tsx index 8db8786..1a1cb33 100644 --- a/src/ui/components/PushSetupWizard.tsx +++ b/src/ui/components/push-setup/PushSetupTasks.tsx @@ -1,7 +1,7 @@ /** - * Push Notification setup wizard component. + * Push Notification setup task components. * - * @module ui/components/PushSetupWizard + * @module ui/components/push-setup/PushSetupTasks */ import * as fs from 'node:fs'; @@ -21,36 +21,72 @@ import { getTeamIdError, PUSH_SETUP_URLS, type PushSetupContext, - type PushSetupPhase, - type PushSetupResult, validateP8File, } from '@/lib/push'; -import { - FirebaseDownloader, - type FirebaseProject, - FirebaseService, - isOAuthConfigured, -} from '@/lib/services/firebase'; +import { FirebaseDownloader, type FirebaseProject, FirebaseService } from '@/lib/services/firebase'; import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; import { detectProjectType } from '@/lib/services/project-detector'; import { isCtrlCInput, useCancelInput } from '@/ui/hooks'; -import { AppleLoginUI } from './AppleLoginUI'; +import { AppleLoginUI } from '../AppleLoginUI'; +import { normalizeInputFilePath } from '../file-input-utils'; + +export interface DetectionResult { + firebaseProjectId: string | null; + bundleId: string | null; + teamId: string | null; +} -interface PushSetupWizardProps { +interface DetectionTaskOptions { projectPath: string; - onComplete: (result: PushSetupResult) => void; - onCancel?: () => void; - /** Pre-detected Bundle ID from ios-setup */ preDetectedBundleId?: string; - /** Pre-detected Firebase Project ID from ios-setup */ preDetectedFirebaseProjectId?: string | null; - /** Pre-detected Team ID from Firebase config */ preDetectedTeamId?: string | null; } -/** - * Detecting phase component. - */ +export interface ApnsKeyAcquisitionResult { + pushKey: NonNullable; + p8FilePath: string | null; +} + +interface ErrorPhaseProps { + error: string; + onRetry: () => void; + onCancel: () => void; +} + +function ErrorPhase({ error, onRetry, onCancel }: ErrorPhaseProps): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onRetry(); + } + }); + + useCancelInput(onCancel); + + return ( + + + + Push Notification Setup Error + + + + ✗ {error} + + + Press Enter to retry, Esc/Ctrl+C to cancel + + + ); +} + function DetectingPhase(): React.ReactElement { return ( { + const result: DetectionResult = { + firebaseProjectId: null, + bundleId: null, + teamId: null, + }; + + try { + const projectType = await detectProjectType(projectPath); + const firebaseService = new FirebaseService(projectPath, projectType); + const detection = await firebaseService.detect(); + + const iosContent = detection.ios?.content as GoogleServiceInfoPlist | undefined; + const androidContent = detection.android?.content as GoogleServicesJson | undefined; + + result.firebaseProjectId = + iosContent?.PROJECT_ID || androidContent?.project_info?.project_id || null; + result.bundleId = iosContent?.BUNDLE_ID || null; + result.teamId = iosContent?.TEAM_ID || null; + } catch { + // Intentionally ignore detection errors and keep fallback values. + } + + return result; +} + +async function detectFromXcodeProject( + projectPath: string, +): Promise<{ teamId: string | null; bundleId: string | null }> { + try { + const analysis = await analyzeIosProject(projectPath); + if (analysis.success && analysis.project) { + return { + teamId: analysis.project.teamId || null, + bundleId: analysis.project.bundleId || null, + }; + } + } catch { + // Intentionally ignore analyzer errors and keep fallback values. + } + + return { teamId: null, bundleId: null }; +} + +function getPreDetectedResult(options: DetectionTaskOptions): DetectionResult | null { + if ( + options.preDetectedBundleId === undefined && + options.preDetectedFirebaseProjectId === undefined && + options.preDetectedTeamId === undefined + ) { + return null; + } + + return { + firebaseProjectId: options.preDetectedFirebaseProjectId ?? null, + bundleId: options.preDetectedBundleId ?? null, + teamId: options.preDetectedTeamId ?? null, + }; +} + +async function resolveDetectionResult(options: DetectionTaskOptions): Promise { + const preDetected = getPreDetectedResult(options); + if (preDetected) { + return preDetected; + } + + const firebaseResult = await detectFromFirebase(options.projectPath); + if (firebaseResult.teamId) { + return firebaseResult; + } + + const xcodeResult = await detectFromXcodeProject(options.projectPath); + return { + firebaseProjectId: firebaseResult.firebaseProjectId, + bundleId: firebaseResult.bundleId || xcodeResult.bundleId, + teamId: xcodeResult.teamId, + }; +} + +type DetectionTaskPhase = 'detecting' | 'status' | 'error'; + +export const PushDetectionTask: React.FC<{ + projectPath: string; + preDetectedBundleId?: string; + preDetectedFirebaseProjectId?: string | null; + preDetectedTeamId?: string | null; + onComplete: (result: DetectionResult) => void; + onCancel: () => void; +}> = ({ + projectPath, + preDetectedBundleId, + preDetectedFirebaseProjectId, + preDetectedTeamId, + onComplete, + onCancel, +}) => { + const [phase, setPhase] = useState('detecting'); + const [error, setError] = useState(null); + const [detected, setDetected] = useState({ + firebaseProjectId: null, + bundleId: null, + teamId: preDetectedTeamId ?? null, + }); + + useEffect(() => { + if (phase !== 'detecting') { + return; + } + + let cancelled = false; + + const detect = async () => { + try { + const detectionResult = await resolveDetectionResult({ + projectPath, + preDetectedBundleId, + preDetectedFirebaseProjectId, + preDetectedTeamId, + }); + if (cancelled) { + return; + } + + setDetected(detectionResult); + setPhase('status'); + } catch (err) { + if (cancelled) { + return; + } + + setError(err instanceof Error ? err.message : 'Failed to detect project configuration'); + setPhase('error'); + } + }; + + void detect(); + + return () => { + cancelled = true; + }; + }, [phase, preDetectedBundleId, preDetectedFirebaseProjectId, preDetectedTeamId, projectPath]); + + if (phase === 'detecting') { + return ; + } + + if (phase === 'error') { + return ( + { + setError(null); + setPhase('detecting'); + }} + onCancel={onCancel} + /> + ); + } + + return ( + onComplete(detected)} + onCancel={onCancel} + /> + ); +}; + function KeySourcePhase({ onHasKey, onNoKey, @@ -191,9 +394,6 @@ function KeySourcePhase({ ); } -/** - * Apple guide phase component. - */ function AppleGuidePhase({ onContinue, onCancel, @@ -251,25 +451,20 @@ function AppleGuidePhase({ ); } -/** - * Find .p8 files in the current directory. - */ function findP8Files(): string[] { try { const cwd = process.cwd(); const files = fs.readdirSync(cwd); + return files - .filter((f) => f.endsWith('.p8')) - .map((f) => `./${f}`) + .filter((fileName) => fileName.endsWith('.p8')) + .map((fileName) => `./${fileName}`) .sort(); } catch { return []; } } -/** - * P8 input phase component. - */ function P8InputPhase({ suggestedKeyId, suggestedTeamId, @@ -290,23 +485,20 @@ function P8InputPhase({ const [extractedKeyId, setExtractedKeyId] = useState(null); const [prefilledTeamId] = useState(suggestedTeamId || null); - // Search for .p8 files on mount useEffect(() => { const files = findP8Files(); setFoundFiles(files); - // If no files found, go directly to manual input if (files.length === 0) { setStage('p8_path'); } }, []); useInput((input, key) => { - // Handle cancel with ESC or Ctrl+C if (key.escape || isCtrlCInput(input, key)) { onCancel(); return; } - // Open Team ID page when 't' is pressed in team_id stage + if (stage === 'team_id' && input === 't') { openBrowser(PUSH_SETUP_URLS.appleTeamId); } @@ -318,13 +510,14 @@ function P8InputPhase({ return; } - const result = validateP8File(item.value); + const normalizedP8Path = normalizeInputFilePath(item.value); + const result = validateP8File(normalizedP8Path); if (!result.valid) { setError(result.error || 'Invalid P8 file'); return; } - setP8Path(item.value); + setP8Path(normalizedP8Path); setError(null); if (result.suggestedKeyId) { setExtractedKeyId(result.suggestedKeyId); @@ -339,12 +532,14 @@ function P8InputPhase({ return; } - const result = validateP8File(p8Path.trim()); + const normalizedP8Path = normalizeInputFilePath(p8Path); + const result = validateP8File(normalizedP8Path); if (!result.valid) { setError(result.error || 'Invalid P8 file'); return; } + setP8Path(normalizedP8Path); setError(null); if (result.suggestedKeyId) { setExtractedKeyId(result.suggestedKeyId); @@ -359,6 +554,7 @@ function P8InputPhase({ setError(keyIdError); return; } + setError(null); setStage('team_id'); }, [keyId]); @@ -369,15 +565,15 @@ function P8InputPhase({ setError(teamIdError); return; } + setError(null); onSubmit(p8Path.trim(), keyId.trim().toUpperCase(), teamId.trim().toUpperCase()); - }, [p8Path, keyId, teamId, onSubmit]); + }, [keyId, onSubmit, p8Path, teamId]); - // Build selection items for found files const fileItems = [ - ...foundFiles.map((f) => ({ - label: `${path.basename(f)}`, - value: f, + ...foundFiles.map((filePath) => ({ + label: `${path.basename(filePath)}`, + value: filePath, })), { label: 'Enter path manually...', value: 'manual' }, ]; @@ -419,15 +615,16 @@ function P8InputPhase({ Path to P8 file: (e.g., ./AuthKey_XXXXXXXXXX.p8) - - Tip: Copy the file to this directory to avoid permission issues + + 1. Drag the .p8 file here → press Enter + 2. Or enter the file path manually - {'> '} + {'>'} @@ -450,7 +647,7 @@ function P8InputPhase({ )} - {'> '} + {'>'} )} - {'> '} + {'>'} void; + onCancel: () => void; +}> = ({ projectPath, suggestedTeamId, onComplete, onCancel }) => { + const [phase, setPhase] = useState('key_source'); + const [error, setError] = useState(null); + + const handleAppleLoginSuccess = useCallback( + (result: { + pushKey: { apnsKeyId: string; apnsKeyP8: string; teamId: string; teamName?: string }; + }) => { + const p8FileName = `AuthKey_${result.pushKey.apnsKeyId}.p8`; + const projectP8Path = path.join(projectPath, p8FileName); + + let savedPath: string | null = null; + try { + fs.writeFileSync(projectP8Path, result.pushKey.apnsKeyP8, { + encoding: 'utf-8', + mode: 0o600, + }); + savedPath = projectP8Path; + } catch { + try { + const fallbackPath = path.join(process.cwd(), p8FileName); + fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, { + encoding: 'utf-8', + mode: 0o600, + }); + savedPath = fallbackPath; + } catch { + setError('Failed to write APNS key file. Check directory permissions and try again.'); + setPhase('error'); + return; + } + } + + onComplete({ + pushKey: { + apnsKeyP8: result.pushKey.apnsKeyP8, + apnsKeyId: result.pushKey.apnsKeyId, + teamId: result.pushKey.teamId, + }, + p8FilePath: savedPath, + }); + }, + [onComplete, projectPath], + ); + + const handleP8Submit = useCallback( + (p8Path: string, keyId: string, teamId: string) => { + const result = validateP8File(p8Path); + if (!result.valid || !result.content) { + setError(result.error || 'Invalid P8 file'); + setPhase('error'); + return; + } + + onComplete({ + pushKey: { + apnsKeyP8: result.content, + apnsKeyId: keyId, + teamId, + }, + p8FilePath: p8Path, + }); + }, + [onComplete], + ); + + if (phase === 'key_source') { + return ( + setPhase('p8_input')} + onNoKey={() => setPhase('apple_guide')} + onAppleLogin={() => setPhase('apple_login')} + onCancel={onCancel} + /> + ); + } + + if (phase === 'apple_login') { + return ( + setPhase('apple_guide')} + /> + ); + } + + if (phase === 'apple_guide') { + return setPhase('p8_input')} onCancel={onCancel} />; + } + + if (phase === 'p8_input') { + return ( + + ); + } + + return ( + { + setError(null); + setPhase('key_source'); + }} + onCancel={onCancel} + /> + ); +}; + function FirebaseAuthPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { useCancelInput(onCancel); @@ -546,15 +860,12 @@ function FirebaseAuthPhase({ onCancel }: { onCancel: () => void }): React.ReactE A browser window will open for authentication - Esc to cancel + Esc/Ctrl+C to cancel ); } -/** - * Firebase project selection phase component. - */ function FirebaseProjectsPhase({ projects, onSelect, @@ -564,14 +875,14 @@ function FirebaseProjectsPhase({ onSelect: (project: FirebaseProject) => void; onCancel: () => void; }): React.ReactElement { - const items = projects.map((p) => ({ - label: p.displayName || p.projectId, - value: p.projectId, + const items = projects.map((project) => ({ + label: project.displayName || project.projectId, + value: project.projectId, })); const handleSelect = useCallback( (item: { label: string; value: string }) => { - const project = projects.find((p) => p.projectId === item.value); + const project = projects.find((candidate) => candidate.projectId === item.value); if (project) { onSelect(project); } @@ -604,23 +915,134 @@ function FirebaseProjectsPhase({ ); } -/** - * Firebase upload phase component. - */ -function FirebaseUploadPhase({ - context, - selectedProject, - onComplete, - onCancel, -}: { - context: PushSetupContext; - selectedProject: FirebaseProject | null; - onComplete: () => void; - onCancel: () => void; -}): React.ReactElement { +type FirebaseProjectTaskPhase = 'authenticating' | 'select_project' | 'error'; + +type ProjectSelectionDecision = + | { type: 'complete'; project: FirebaseProject } + | { type: 'select' } + | { type: 'error'; message: string }; + +function decideProjectSelection( + projects: FirebaseProject[], + preferredProjectId: string | null, +): ProjectSelectionDecision { + if (preferredProjectId) { + const matchingProject = projects.find((project) => project.projectId === preferredProjectId); + if (matchingProject) { + return { type: 'complete', project: matchingProject }; + } + } + + if (projects.length === 1) { + return { type: 'complete', project: projects[0] }; + } + + if (projects.length === 0) { + return { type: 'error', message: 'No Firebase projects found' }; + } + + return { type: 'select' }; +} + +export const FirebaseProjectSelectionTask: React.FC<{ + preferredProjectId: string | null; + onComplete: (project: FirebaseProject | null) => void; + onCancel: () => void; +}> = ({ preferredProjectId, onComplete, onCancel }) => { + const [phase, setPhase] = useState('authenticating'); + const [projects, setProjects] = useState([]); + const [error, setError] = useState(null); + const downloaderRef = useRef(null); + + useEffect(() => { + if (phase !== 'authenticating') { + return; + } + + let cancelled = false; + + const authenticateAndSelect = async () => { + try { + downloaderRef.current = downloaderRef.current ?? new FirebaseDownloader(); + const downloader = downloaderRef.current; + const authResult = await downloader.authenticate(openBrowser); + if (!authResult.success) { + throw new Error(authResult.error || 'Firebase authentication failed'); + } + + const fetchedProjects = await downloader.listProjects(); + if (cancelled) { + return; + } + + setProjects(fetchedProjects); + const decision = decideProjectSelection(fetchedProjects, preferredProjectId); + if (decision.type === 'complete') { + onComplete(decision.project); + return; + } + if (decision.type === 'error') { + setError(decision.message); + setPhase('error'); + return; + } + setPhase('select_project'); + } catch (err) { + if (cancelled) { + return; + } + + setError(err instanceof Error ? err.message : 'Firebase authentication failed'); + setPhase('error'); + } + }; + + void authenticateAndSelect(); + + return () => { + cancelled = true; + }; + }, [onComplete, phase, preferredProjectId]); + + if (phase === 'authenticating') { + return ; + } + + if (phase === 'select_project') { + return ( + onComplete(project)} + onCancel={onCancel} + /> + ); + } + + return ( + { + setError(null); + setPhase('authenticating'); + }} + onCancel={onCancel} + /> + ); +}; + +function FirebaseUploadPhase({ + context, + selectedProject, + onComplete, + onCancel, +}: { + context: PushSetupContext; + selectedProject: FirebaseProject | null; + onComplete: () => void; + onCancel: () => void; +}): React.ReactElement { const [browserOpened, setBrowserOpened] = useState(false); - // Compute project label with fallback const projectLabel = selectedProject?.displayName || selectedProject?.projectId || @@ -629,16 +1051,14 @@ function FirebaseUploadPhase({ useEffect(() => { if (!browserOpened) { - // Use selectedProject if available, otherwise fall back to context.firebaseProjectId const projectId = selectedProject?.projectId ?? context.firebaseProjectId; - // If no project ID is known, open the generic Firebase console const url = projectId ? PUSH_SETUP_URLS.firebaseConsole(projectId) : PUSH_SETUP_URLS.firebaseConsoleGeneric; openBrowser(url); setBrowserOpened(true); } - }, [browserOpened, selectedProject, context.firebaseProjectId]); + }, [browserOpened, context.firebaseProjectId, selectedProject]); useInput((_input, key) => { if (key.return) { @@ -716,534 +1136,18 @@ function FirebaseUploadPhase({ ); } -/** - * Complete phase component. - */ -function CompletePhase({ - context, - cancelled, -}: { +export const FirebaseApnsRegistrationTask: React.FC<{ context: PushSetupContext; - cancelled: boolean; -}): React.ReactElement { - if (cancelled) { - return ( - - - - ! Push notification setup cancelled - - - - You can run /ios-setup later to configure push notifications. - - - ); - } - - return ( - - - - ✓ Push Notification Setup Complete - - - - - Key ID: - {context.pushKey?.apnsKeyId || 'N/A'} - - - Team ID: - {context.pushKey?.teamId || 'N/A'} - - - - Your iOS app is now configured to receive push notifications. - - - ); -} - -/** - * Error phase component. - */ -function ErrorPhase({ - error, - onRetry, - onCancel, -}: { - error: string; - onRetry: () => void; + selectedProject: FirebaseProject | null; + onComplete: () => void; onCancel: () => void; -}): React.ReactElement { - useInput((_input, key) => { - if (key.return) { - onRetry(); - } - }); - - useCancelInput(onCancel); - +}> = ({ context, selectedProject, onComplete, onCancel }) => { return ( - - - - Push Notification Setup Error - - - - ✗ {error} - - - Press Enter to retry, Esc/Ctrl+C to cancel - - + ); -} - -/** - * Detection result from project analysis. - */ -interface DetectionResult { - firebaseProjectId: string | null; - bundleId: string | null; - teamId: string | null; -} - -/** - * Detect project configuration from Firebase config. - */ -async function detectFromFirebase(projectPath: string): Promise { - const result: DetectionResult = { - firebaseProjectId: null, - bundleId: null, - teamId: null, - }; - - try { - const projectType = await detectProjectType(projectPath); - const firebaseService = new FirebaseService(projectPath, projectType); - const detection = await firebaseService.detect(); - - const iosContent = detection.ios?.content as GoogleServiceInfoPlist | undefined; - const androidContent = detection.android?.content as GoogleServicesJson | undefined; - - result.firebaseProjectId = - iosContent?.PROJECT_ID || androidContent?.project_info?.project_id || null; - result.bundleId = iosContent?.BUNDLE_ID || null; - result.teamId = iosContent?.TEAM_ID || null; - } catch { - // Firebase detection failed - } - - return result; -} - -/** - * Detect Team ID and Bundle ID from Xcode project. - */ -async function detectFromXcodeProject( - projectPath: string, -): Promise<{ teamId: string | null; bundleId: string | null }> { - try { - const analysis = await analyzeIosProject(projectPath); - if (analysis.success && analysis.project) { - return { - teamId: analysis.project.teamId || null, - bundleId: analysis.project.bundleId || null, - }; - } - } catch { - // iOS project analysis failed - } - return { teamId: null, bundleId: null }; -} - -/** - * Push notification setup wizard component. - */ -export const PushSetupWizard: React.FC = ({ - projectPath, - onComplete, - onCancel, - preDetectedBundleId, - preDetectedFirebaseProjectId, - preDetectedTeamId, -}) => { - const [phase, setPhase] = useState('detecting'); - const [context, setContext] = useState({ - bundleId: null, - firebaseProjectId: null, - pushKey: null, - p8FilePath: null, - }); - const [error, setError] = useState(null); - const [cancelled, setCancelled] = useState(false); - const [detectedTeamId, setDetectedTeamId] = useState(preDetectedTeamId ?? null); - - // Firebase OAuth state - const downloaderRef = useRef(null); - const [projects, setProjects] = useState([]); - const [selectedProject, setSelectedProject] = useState(null); - - // Helper to apply detection results to state - const applyDetectionResult = useCallback( - (result: { - firebaseProjectId: string | null; - bundleId: string | null; - teamId: string | null; - }) => { - if (result.teamId) { - setDetectedTeamId(result.teamId); - } - setContext((prev) => ({ - ...prev, - firebaseProjectId: result.firebaseProjectId, - bundleId: result.bundleId, - })); - setPhase('status'); - }, - [], - ); - - // Initial detection - useEffect(() => { - let cancelled = false; - - const detect = async () => { - // Use pre-detected values if available (from ios-setup integration) - if (preDetectedBundleId !== undefined || preDetectedFirebaseProjectId !== undefined) { - if (cancelled) return; - applyDetectionResult({ - firebaseProjectId: preDetectedFirebaseProjectId ?? null, - bundleId: preDetectedBundleId ?? null, - teamId: preDetectedTeamId ?? null, - }); - return; - } - - // Detect from Firebase config - const firebaseResult = await detectFromFirebase(projectPath); - if (cancelled) return; - let { firebaseProjectId, bundleId, teamId } = firebaseResult; - - // Try Xcode project if Team ID not found in Firebase config - if (!teamId) { - const xcodeResult = await detectFromXcodeProject(projectPath); - if (cancelled) return; - teamId = xcodeResult.teamId; - bundleId = bundleId || xcodeResult.bundleId; - } - - applyDetectionResult({ firebaseProjectId, bundleId, teamId }); - }; - - if (phase === 'detecting') { - detect(); - } - - return () => { - cancelled = true; - }; - }, [ - phase, - projectPath, - preDetectedBundleId, - preDetectedFirebaseProjectId, - preDetectedTeamId, - applyDetectionResult, - ]); - - // Firebase authentication effect - useEffect(() => { - let cancelled = false; - - const handleProjectSelection = (fetchedProjects: FirebaseProject[]) => { - // If pre-detected project exists, try to find and auto-select it - if (context.firebaseProjectId) { - const matchingProject = fetchedProjects.find( - (p) => p.projectId === context.firebaseProjectId, - ); - if (matchingProject) { - setSelectedProject(matchingProject); - setPhase('firebase_upload'); - return true; - } - } - - // Otherwise, show project selection based on count - if (fetchedProjects.length === 1) { - setSelectedProject(fetchedProjects[0]); - setPhase('firebase_upload'); - } else if (fetchedProjects.length > 1) { - setPhase('firebase_projects'); - } else { - setError('No Firebase projects found'); - setPhase('error'); - } - return false; - }; - - const authenticateAndFetchProjects = async () => { - try { - if (!downloaderRef.current) { - downloaderRef.current = new FirebaseDownloader(); - } - const downloader = downloaderRef.current; - - await downloader.authenticate(openBrowser); - - const fetchedProjects = await downloader.listProjects(); - if (cancelled) return; - - setProjects(fetchedProjects); - handleProjectSelection(fetchedProjects); - } catch (err) { - if (cancelled) return; - const message = err instanceof Error ? err.message : 'Firebase authentication failed'; - setError(message); - setPhase('error'); - } - }; - - if (phase === 'firebase_auth') { - authenticateAndFetchProjects(); - } - - return () => { - cancelled = true; - }; - }, [phase, context.firebaseProjectId]); - - const handleContinue = useCallback(() => { - setPhase('key_source'); - }, []); - - const handleCancel = useCallback(() => { - setCancelled(true); - setPhase('complete'); - if (onCancel) { - onCancel(); - } else { - onComplete({ - success: false, - message: 'Push notification setup cancelled', - }); - } - }, [onCancel, onComplete]); - - const handleHasKey = useCallback(() => { - setPhase('p8_input'); - }, []); - - const handleNoKey = useCallback(() => { - setPhase('apple_guide'); - }, []); - - const handleAppleLogin = useCallback(() => { - setPhase('apple_login'); - }, []); - - const handleAppleLoginSuccess = useCallback( - (result: { - pushKey: { apnsKeyId: string; apnsKeyP8: string; teamId: string; teamName?: string }; - }) => { - // Save P8 content to a file in the project directory - const p8FileName = `AuthKey_${result.pushKey.apnsKeyId}.p8`; - const p8FilePath = path.join(projectPath, p8FileName); - - let savedPath: string | null = null; - try { - // Use mode 0o600 to restrict APNS key file to current user only - fs.writeFileSync(p8FilePath, result.pushKey.apnsKeyP8, { encoding: 'utf-8', mode: 0o600 }); - savedPath = p8FilePath; - } catch { - // If we can't write to project dir, try current dir - try { - const fallbackPath = path.join(process.cwd(), p8FileName); - fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, { - encoding: 'utf-8', - mode: 0o600, - }); - savedPath = fallbackPath; - } catch { - setError('Failed to write APNS key file. Check directory permissions and try again.'); - setPhase('error'); - return; - } - } - - setContext((prev) => ({ - ...prev, - pushKey: { - apnsKeyP8: result.pushKey.apnsKeyP8, - apnsKeyId: result.pushKey.apnsKeyId, - teamId: result.pushKey.teamId, - }, - p8FilePath: savedPath, - })); - // Proceed to Firebase upload - if (isOAuthConfigured()) { - setPhase('firebase_auth'); - } else { - setPhase('firebase_upload'); - } - }, - [projectPath], - ); - - const handleAppleLoginFallback = useCallback(() => { - // Fall back to manual browser-based key creation - setPhase('apple_guide'); - }, []); - - const handleAppleGuideComplete = useCallback(() => { - setPhase('p8_input'); - }, []); - - const handleP8Submit = useCallback((p8Path: string, keyId: string, teamId: string) => { - const result = validateP8File(p8Path); - if (!result.valid || !result.content) { - setError(result.error || 'Invalid P8 file'); - setPhase('error'); - return; - } - - // Store content in a variable to ensure TypeScript type narrowing - const p8Content = result.content; - - setContext((prev) => ({ - ...prev, - pushKey: { - apnsKeyP8: p8Content, - apnsKeyId: keyId, - teamId, - }, - p8FilePath: p8Path, - })); - // Check if Firebase OAuth is configured - if (isOAuthConfigured()) { - setPhase('firebase_auth'); - } else { - // Fallback: open Firebase console directly with detected project - setPhase('firebase_upload'); - } - }, []); - - const handleProjectSelect = useCallback((project: FirebaseProject) => { - setSelectedProject(project); - setPhase('firebase_upload'); - }, []); - - const handleFirebaseComplete = useCallback(() => { - setPhase('complete'); - onComplete({ - success: true, - message: 'Push notification setup complete', - context, - }); - }, [context, onComplete]); - - const handleRetry = useCallback(() => { - setError(null); - setPhase('status'); - }, []); - - switch (phase) { - case 'detecting': - return ; - - case 'status': - return ; - - case 'key_source': - return ( - - ); - - case 'apple_login': - return ( - - ); - - case 'apple_guide': - return ; - - case 'p8_input': - return ( - - ); - - case 'firebase_auth': - return ; - - case 'firebase_projects': - return ( - - ); - - case 'firebase_upload': - return ( - - ); - - case 'complete': - return ; - - case 'error': - return ( - - ); - - default: - return ; - } }; From d2a75d5618c08de92f6b95b00093a15adb69da9b Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Tue, 17 Feb 2026 17:22:27 -0800 Subject: [PATCH 08/30] docs: add execution discipline to AGENTS guide --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1151d78..d8d89d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,15 @@ Clix CLI is an interactive AI-powered assistant for Clix SDK integration. Built - When fixing lint/test failures, fix ALL issues in one pass, not incrementally. - Do not create helper functions that are referenced only once. +## Execution Discipline + +Guidelines merged from `andrej-karpathy-skills/CLAUDE.md` and adapted for this repository: + +- **Think before coding**: State assumptions explicitly. If requirements are ambiguous, clarify before implementation. +- **Keep it simple**: Implement only what was requested. Avoid speculative abstraction, configurability, or impossible-case handling. +- **Make surgical changes**: Touch only code directly related to the request. Do not clean up unrelated code. +- **Define verifiable goals**: Convert tasks into concrete checks (targeted tests, repro case, lint/typecheck/build) and verify completion. + ## Commands ```bash From 61075a76014642c6bad29ebe1d2553a59171c97b Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Tue, 17 Feb 2026 17:25:29 -0800 Subject: [PATCH 09/30] docs(agents): redistribute CLAUDE guidance into existing sections --- AGENTS.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8d89d9..2a088e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,19 +11,15 @@ Clix CLI is an interactive AI-powered assistant for Clix SDK integration. Built - After any code change, run `bun run check && bun test` automatically. Do not ask permission to run tests. - Before introducing a new pattern, search the codebase for existing implementations first. - Prefer automation over confirmation — run tests, fix lint issues, and verify results yourself. +- State assumptions explicitly before implementation. If requirements are ambiguous or conflicting, ask first. +- If multiple valid interpretations exist, present the options and tradeoffs instead of choosing silently. +- Prefer the simplest implementation that satisfies the request. Do not add speculative features or abstractions. +- Do not add configurability/flexibility unless requested, and avoid handling impossible scenarios. - Only modify files directly related to the task. Do not refactor or "improve" surrounding code. +- Do not clean up unrelated code. Remove only unused imports/variables/functions introduced by your own changes. - When fixing lint/test failures, fix ALL issues in one pass, not incrementally. - Do not create helper functions that are referenced only once. -## Execution Discipline - -Guidelines merged from `andrej-karpathy-skills/CLAUDE.md` and adapted for this repository: - -- **Think before coding**: State assumptions explicitly. If requirements are ambiguous, clarify before implementation. -- **Keep it simple**: Implement only what was requested. Avoid speculative abstraction, configurability, or impossible-case handling. -- **Make surgical changes**: Touch only code directly related to the request. Do not clean up unrelated code. -- **Define verifiable goals**: Convert tasks into concrete checks (targeted tests, repro case, lint/typecheck/build) and verify completion. - ## Commands ```bash @@ -237,6 +233,10 @@ Tests use `bun:test` (`describe`, `test`, `expect`, `mock`). **Test hierarchy**: First run the specific file test (`bun test path/to/file.test.ts`). If shared code changed, run full suite (`bun test`). +- Use goal-driven verification: define concrete success checks before coding and loop until each check passes. +- For bug fixes, add or run a test that reproduces the issue, then verify it passes after the fix. +- For refactors, verify behavior is unchanged with before/after test results. + **Mocking**: Use `mock.module()` for module mocks. Keep tests deterministic — avoid network calls. ```typescript From de0145dda246caa08aa016b1f08bd4aa4c2710a1 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 19 Feb 2026 12:29:36 -0800 Subject: [PATCH 10/30] feat: ensure main app target entitlements linkage during iOS setup and refine related UI and Firebase integration. --- src/cli.tsx | 6 +- .../skill/__tests__/preparation.test.ts | 125 +++ src/commands/skill/index.tsx | 38 +- src/commands/skill/preparation.ts | 70 +- .../ios/__tests__/pbxproj-modifier.test.ts | 456 ++++++++ src/lib/ios/extension-generator.ts | 152 ++- src/lib/ios/index.ts | 10 + src/lib/ios/pbxproj-modifier.ts | 985 +++++++++++++++++- src/lib/ios/podfile-modifier.ts | 57 +- src/lib/ios/push-key.ts | 26 +- src/lib/push/constants.ts | 11 +- .../firebase/__tests__/downloader.test.ts | 62 ++ src/lib/services/firebase/downloader.ts | 11 + .../oauth/__tests__/auth-client.test.ts | 33 + .../services/firebase/oauth/auth-client.ts | 15 +- .../__tests__/oauth-callback-server.test.ts | 31 + src/lib/utils/oauth.ts | 79 +- src/ui/IosSetupUI.tsx | 205 +++- src/ui/LoginUI.tsx | 18 +- src/ui/SetupUI.tsx | 18 +- src/ui/chat/components/ChatInput.tsx | 88 +- src/ui/components/AppleLoginUI.tsx | 337 ++++-- .../components/FirebaseConfigFilesSetup.tsx | 68 +- .../FirebaseServiceAccountSetup.tsx | 18 +- src/ui/components/InstallPreparationUI.tsx | 607 +++++++++-- .../NotificationExtensionTasks.tsx | 283 +++-- .../components/push-setup/PushSetupTasks.tsx | 149 ++- src/ui/utils/terminalHyperlink.ts | 26 + 28 files changed, 3383 insertions(+), 601 deletions(-) create mode 100644 src/lib/ios/__tests__/pbxproj-modifier.test.ts create mode 100644 src/lib/services/firebase/__tests__/downloader.test.ts create mode 100644 src/lib/services/firebase/oauth/__tests__/auth-client.test.ts create mode 100644 src/lib/utils/__tests__/oauth-callback-server.test.ts create mode 100644 src/ui/utils/terminalHyperlink.ts diff --git a/src/cli.tsx b/src/cli.tsx index 7170720..61d2ebe 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -79,6 +79,9 @@ const cli = meow(generateHelpText(), { platform: { type: 'string', }, + startTask: { + type: 'string', + }, interactive: { type: 'boolean', default: false, @@ -198,7 +201,8 @@ async function main() { | 'react-native' | 'flutter' | undefined; - await skillCommand({ action: command, platform }); + const startTask = cli.flags.startTask; + await skillCommand({ action: command, platform, startTask }); } else if (command) { // Unknown command - show error message console.error(`Unknown command: ${command}`); diff --git a/src/commands/skill/__tests__/preparation.test.ts b/src/commands/skill/__tests__/preparation.test.ts index 22c70b0..671b2d1 100644 --- a/src/commands/skill/__tests__/preparation.test.ts +++ b/src/commands/skill/__tests__/preparation.test.ts @@ -76,6 +76,50 @@ const mockIos = { (bundleId: string) => `group.clix.${bundleId}`, ), getIosProjectDir: mock<(projectPath: string) => string>(() => '/test/ios'), + getExtensionName: mock<(appName: string) => string>( + (appName: string) => `${appName}NotificationServiceExtension`, + ), + getExtensionBundleId: mock<(bundleId: string, appName: string) => string>( + (bundleId: string, appName: string) => `${bundleId}.${appName}NotificationServiceExtension`, + ), + hasNotificationServiceExtension: mock<(projectPath: string, extensionName: string) => boolean>( + () => false, + ), + getNotificationServiceExtensionStatus: mock(() => ({ + targetExists: false, + buildSettings: { + enableUserScriptSandboxingNo: false, + infoPlistConfigured: false, + codeSignEntitlementsConfigured: false, + }, + })), + hasPodfile: mock<(iosDir: string) => boolean>(() => false), + hasExtensionTarget: mock<(iosDir: string, extensionName: string) => boolean>(() => false), + hasClixPodInExtensionTarget: mock<(iosDir: string, extensionName: string) => boolean>( + () => false, + ), + inspectNotificationServiceSwift: mock< + ( + iosDir: string, + appName: string, + ) => { + exists: boolean; + path: string; + importsClix: boolean; + inheritsClixNse: boolean; + hasRegisterCall: boolean; + hasSuperDidReceive: boolean; + registeredProjectId: string | null; + } + >(() => ({ + exists: false, + path: '/test/ios/MyAppNotificationServiceExtension/NotificationService.swift', + importsClix: false, + inheritsClixNse: false, + hasRegisterCall: false, + hasSuperDidReceive: false, + registeredProjectId: null, + })), hasClixConfiguration: mock< ( entitlements: MockEntitlementsData | null, @@ -106,9 +150,17 @@ mock.module('@/lib/api', () => ({ mock.module('@/lib/ios', () => ({ analyzeIosProject: mockIos.analyzeIosProject, + getExtensionName: mockIos.getExtensionName, + getExtensionBundleId: mockIos.getExtensionBundleId, + getNotificationServiceExtensionStatus: mockIos.getNotificationServiceExtensionStatus, + hasNotificationServiceExtension: mockIos.hasNotificationServiceExtension, + hasPodfile: mockIos.hasPodfile, + hasExtensionTarget: mockIos.hasExtensionTarget, + hasClixPodInExtensionTarget: mockIos.hasClixPodInExtensionTarget, generateAppGroupId: mockIos.generateAppGroupId, getIosProjectDir: mockIos.getIosProjectDir, hasClixConfiguration: mockIos.hasClixConfiguration, + inspectNotificationServiceSwift: mockIos.inspectNotificationServiceSwift, readEntitlements: mockIos.readEntitlements, verifyExtensionFiles: mockIos.verifyExtensionFiles, })); @@ -130,7 +182,15 @@ describe('preparation', () => { mockIos.analyzeIosProject.mockClear(); mockIos.generateAppGroupId.mockClear(); mockIos.getIosProjectDir.mockClear(); + mockIos.getExtensionName.mockClear(); + mockIos.getExtensionBundleId.mockClear(); + mockIos.getNotificationServiceExtensionStatus.mockClear(); + mockIos.hasNotificationServiceExtension.mockClear(); + mockIos.hasPodfile.mockClear(); + mockIos.hasExtensionTarget.mockClear(); + mockIos.hasClixPodInExtensionTarget.mockClear(); mockIos.hasClixConfiguration.mockClear(); + mockIos.inspectNotificationServiceSwift.mockClear(); mockIos.readEntitlements.mockClear(); mockIos.verifyExtensionFiles.mockClear(); @@ -142,7 +202,34 @@ describe('preparation', () => { ); mockIos.generateAppGroupId.mockImplementation((bundleId: string) => `group.clix.${bundleId}`); mockIos.getIosProjectDir.mockImplementation(() => '/test/ios'); + mockIos.getExtensionName.mockImplementation( + (appName: string) => `${appName}NotificationServiceExtension`, + ); + mockIos.getExtensionBundleId.mockImplementation( + (bundleId: string, appName: string) => `${bundleId}.${appName}NotificationServiceExtension`, + ); + mockIos.getNotificationServiceExtensionStatus.mockImplementation(() => ({ + targetExists: false, + buildSettings: { + enableUserScriptSandboxingNo: false, + infoPlistConfigured: false, + codeSignEntitlementsConfigured: false, + }, + })); + mockIos.hasNotificationServiceExtension.mockImplementation(() => false); + mockIos.hasPodfile.mockImplementation(() => false); + mockIos.hasExtensionTarget.mockImplementation(() => false); + mockIos.hasClixPodInExtensionTarget.mockImplementation(() => false); mockIos.hasClixConfiguration.mockImplementation(() => ({ hasPush: false, hasAppGroup: false })); + mockIos.inspectNotificationServiceSwift.mockImplementation(() => ({ + exists: false, + path: '/test/ios/MyAppNotificationServiceExtension/NotificationService.swift', + importsClix: false, + inheritsClixNse: false, + hasRegisterCall: false, + hasSuperDidReceive: false, + registeredProjectId: null, + })); mockIos.readEntitlements.mockImplementation(() => Promise.resolve(null)); mockIos.verifyExtensionFiles.mockImplementation(() => ({ complete: false, @@ -219,6 +306,25 @@ describe('preparation', () => { }); mockIos.hasClixConfiguration.mockReturnValueOnce({ hasPush: true, hasAppGroup: true }); mockIos.verifyExtensionFiles.mockReturnValueOnce({ complete: true, missingFiles: [] }); + mockIos.hasNotificationServiceExtension.mockReturnValueOnce(true); + mockIos.getNotificationServiceExtensionStatus.mockReturnValueOnce({ + targetExists: true, + buildSettings: { + enableUserScriptSandboxingNo: true, + infoPlistConfigured: true, + codeSignEntitlementsConfigured: true, + }, + }); + mockIos.hasPodfile.mockReturnValueOnce(false); + mockIos.inspectNotificationServiceSwift.mockReturnValueOnce({ + exists: true, + path: '/test/ios/MyAppNotificationServiceExtension/NotificationService.swift', + importsClix: true, + inheritsClixNse: true, + hasRegisterCall: true, + hasSuperDidReceive: true, + registeredProjectId: 'project-id', + }); const status = await checkIosStatus('/test', projectType); @@ -256,6 +362,25 @@ describe('preparation', () => { }); mockIos.hasClixConfiguration.mockReturnValueOnce({ hasPush: true, hasAppGroup: true }); mockIos.verifyExtensionFiles.mockReturnValueOnce({ complete: true, missingFiles: [] }); + mockIos.hasNotificationServiceExtension.mockReturnValueOnce(true); + mockIos.getNotificationServiceExtensionStatus.mockReturnValueOnce({ + targetExists: true, + buildSettings: { + enableUserScriptSandboxingNo: true, + infoPlistConfigured: true, + codeSignEntitlementsConfigured: true, + }, + }); + mockIos.hasPodfile.mockReturnValueOnce(false); + mockIos.inspectNotificationServiceSwift.mockReturnValueOnce({ + exists: true, + path: '/test/ios/MyAppNotificationServiceExtension/NotificationService.swift', + importsClix: true, + inheritsClixNse: true, + hasRegisterCall: true, + hasSuperDidReceive: true, + registeredProjectId: 'project-id', + }); const status = await checkIosStatus('/test', projectType); diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 26d18aa..867a49f 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -8,6 +8,10 @@ import { } from '../../lib/skills'; import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; import { InstallPreparationUI } from '../../ui/components/InstallPreparationUI'; +import { + INSTALL_TASK_LABELS, + type InstallTaskId, +} from '../../ui/components/install-preparation-tasks'; import { printFinalOutput } from '../../ui/utils/finalOutput'; import { safeRender } from '../../ui/utils/safeRender'; import type { PreparationContext } from './preparation'; @@ -15,6 +19,7 @@ import type { PreparationContext } from './preparation'; interface SkillCommandOptions { action?: string; platform?: 'ios' | 'android' | 'react-native' | 'flutter'; + startTask?: string; } /** @@ -59,11 +64,15 @@ Examples: /** * Run install preparation UI and return the context. */ -async function runInstallPreparation(projectPath: string): Promise { +async function runInstallPreparation( + projectPath: string, + startTaskId?: InstallTaskId, +): Promise { return new Promise((resolve) => { const { unmount } = safeRender( { unmount(); resolve(context); @@ -78,7 +87,7 @@ async function runInstallPreparation(projectPath: string): Promise { - const { action, platform } = options; + const { action, platform, startTask } = options; if (!action || !isValidSkillType(action)) { console.log(generateHelpText()); @@ -107,11 +116,34 @@ export async function skillCommand(options: SkillCommandOptions): Promise const skillType = action as SkillType; const projectPath = process.cwd(); + let startTaskId: InstallTaskId | undefined; + + if (startTask) { + const installTaskIds = Object.keys(INSTALL_TASK_LABELS) as InstallTaskId[]; + if (!installTaskIds.includes(startTask as InstallTaskId)) { + console.error(`Invalid --start-task value: ${startTask}`); + console.error(`Allowed values: ${installTaskIds.join(', ')}`); + process.exit(1); + } + + if (skillType !== 'install') { + console.error('--start-task is only supported with the install command.'); + process.exit(1); + } + + if (process.env.CLIX_DEV_ENABLE_TASK_OVERRIDE !== '1') { + console.error('--start-task is a development-only option.'); + console.error('Set CLIX_DEV_ENABLE_TASK_OVERRIDE=1 to enable task override.'); + process.exit(1); + } + + startTaskId = startTask as InstallTaskId; + } // For install skill, run preparation first let preparationContext: PreparationContext | undefined; if (skillType === 'install') { - const context = await runInstallPreparation(projectPath); + const context = await runInstallPreparation(projectPath, startTaskId); if (!context) { // User cancelled or config missing return; diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts index 44a228e..b427dd7 100644 --- a/src/commands/skill/preparation.ts +++ b/src/commands/skill/preparation.ts @@ -17,8 +17,16 @@ import { import { analyzeIosProject, generateAppGroupId, + getExtensionBundleId, + getExtensionName, getIosProjectDir, + getNotificationServiceExtensionStatus, hasClixConfiguration, + hasClixPodInExtensionTarget, + hasExtensionTarget, + hasNotificationServiceExtension, + hasPodfile, + inspectNotificationServiceSwift, readEntitlements, verifyExtensionFiles, } from '@/lib/ios'; @@ -59,6 +67,18 @@ export interface IosStatus { entitlementsConfigured: boolean; /** Whether NSE is configured */ nseConfigured: boolean; + /** Detailed evidence for NSE setup verification */ + nseDetails?: NseVerificationStatus; +} + +export interface NseVerificationStatus { + extensionName?: string; + extensionBundleId?: string; + filesComplete: boolean; + xcodeTargetConfigured: boolean; + buildSettingsConfigured: boolean; + podDependencyConfigured: boolean; + notificationServiceConfigured: boolean; } /** @@ -262,6 +282,7 @@ interface IosFileStatus { appGroupId?: string; entitlementsConfigured: boolean; nseConfigured: boolean; + nseDetails?: NseVerificationStatus; } /** @@ -313,14 +334,59 @@ async function detectIosStatusFromFiles( } const iosDir = getIosProjectDir(projectPath); - const hasNseFiles = iosDir ? verifyExtensionFiles(iosDir, project.appName).complete : false; + const extensionName = getExtensionName(project.appName); + const extensionBundleId = getExtensionBundleId(project.bundleId, project.appName); + + const filesComplete = iosDir ? verifyExtensionFiles(iosDir, project.appName).complete : false; + const xcodeTargetConfigured = hasNotificationServiceExtension(project.projectPath, extensionName); + + const extensionStatus = getNotificationServiceExtensionStatus(project.projectPath, extensionName); + const buildSettingsConfigured = + extensionStatus.buildSettings.enableUserScriptSandboxingNo && + extensionStatus.buildSettings.infoPlistConfigured && + extensionStatus.buildSettings.codeSignEntitlementsConfigured; + + const podfileExists = iosDir ? hasPodfile(iosDir) : false; + const podDependencyConfigured = + !podfileExists || + (iosDir + ? hasExtensionTarget(iosDir, extensionName) && + hasClixPodInExtensionTarget(iosDir, extensionName) + : false); + + const swiftStatus = iosDir ? inspectNotificationServiceSwift(iosDir, project.appName) : null; + const notificationServiceConfigured = + Boolean(swiftStatus?.exists) && + Boolean(swiftStatus?.importsClix) && + Boolean(swiftStatus?.inheritsClixNse) && + Boolean(swiftStatus?.hasRegisterCall) && + Boolean(swiftStatus?.hasSuperDidReceive) && + Boolean(swiftStatus?.registeredProjectId) && + swiftStatus?.registeredProjectId !== 'YOUR_PROJECT_ID'; + + const hasNse = Boolean( + filesComplete && + xcodeTargetConfigured && + buildSettingsConfigured && + podDependencyConfigured && + notificationServiceConfigured, + ); return { bundleId: project.bundleId, teamId: project.teamId, appGroupId: detectedAppGroupId ?? setup?.appGroupId, entitlementsConfigured: hasEntitlements, - nseConfigured: hasNseFiles, + nseConfigured: hasNse, + nseDetails: { + extensionName, + extensionBundleId, + filesComplete, + xcodeTargetConfigured, + buildSettingsConfigured, + podDependencyConfigured, + notificationServiceConfigured, + }, }; } diff --git a/src/lib/ios/__tests__/pbxproj-modifier.test.ts b/src/lib/ios/__tests__/pbxproj-modifier.test.ts new file mode 100644 index 0000000..768077a --- /dev/null +++ b/src/lib/ios/__tests__/pbxproj-modifier.test.ts @@ -0,0 +1,456 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +let nativeTargetSection: Record | null = null; +let currentCodeSignEntitlements: string | undefined; +let strictAllUuidsMode = false; +let strictProductTypeMode = false; +let strictBuildFileSectionMode = false; +let strictFileReferenceMode = false; +let strictFallbackBuildPhaseMode = false; + +const parseSyncMock = mock(() => {}); +const writeSyncMock = mock(() => '// updated pbxproj'); +const getBuildPropertyMock = mock( + (_property: string, _build?: string, _targetName?: string) => currentCodeSignEntitlements, +); +const updateBuildPropertyMock = mock(function ( + this: { pbxTargetByName?: (targetName: string) => unknown } | undefined, + _property: string, + value: string, + _build?: string, + _targetName?: string, +) { + if (!this || typeof this.pbxTargetByName !== 'function') { + throw new TypeError("undefined is not an object (evaluating 'this.pbxTargetByName')"); + } + currentCodeSignEntitlements = value; +}); +const allUuidsMock = mock(() => { + if (strictAllUuidsMode) { + throw new ReferenceError('key is not defined'); + } + return [] as string[]; +}); +const generateUuidMock = mock(function (this: { allUuids?: () => string[] } | undefined) { + this?.allUuids?.(); + return 'GENERATED_UUID'; +}); +const addTargetMock = mock(function ( + this: { generateUuid?: () => string } | undefined, + _name: string, + _type: string, + _subfolder: string, + _bundleId: string, +) { + if (strictProductTypeMode) { + throw new ReferenceError('PRODUCTTYPE_BY_TARGETTYPE is not defined'); + } + if (strictBuildFileSectionMode) { + throw new TypeError( + "undefined is not an object (evaluating 'this.pbxBuildFileSection()[file2.uuid] = pbxBuildFileObj(file2)')", + ); + } + if (strictFileReferenceMode) { + throw new ReferenceError('fileReference is not defined'); + } + this?.generateUuid?.(); + return { uuid: 'NEW_EXTENSION_TARGET_UUID' }; +}); +const addXCConfigurationListMock = mock( + ( + _buildConfigurations: Array<{ + name: string; + isa: string; + buildSettings: Record; + }>, + _defaultConfigurationName: string, + _comment: string, + ) => ({ uuid: 'FALLBACK_BUILD_CONFIG_UUID' }), +); +const addProductFileMock = mock( + (_name: string, _options: { group: string; target: string; explicitFileType: string }) => ({ + uuid: 'FALLBACK_PRODUCT_BUILD_FILE_UUID', + fileRef: 'FALLBACK_PRODUCT_FILE_REF', + basename: 'MyAppNotificationServiceExtension', + }), +); +const addToPbxBuildFileSectionMock = mock((_file: { fileRef: string; basename: string }) => {}); +const addToPbxNativeTargetSectionMock = mock( + (_target: { uuid: string; pbxNativeTarget: Record }) => {}, +); +const addBuildPhaseMock = mock( + ( + _files: string[], + _buildPhaseType: string, + _comment: string, + _targetUuid: string, + _targetType?: string, + ) => { + if (strictFallbackBuildPhaseMode) { + throw new ReferenceError('fileReference is not defined'); + } + }, +); +const addToPbxCopyfilesBuildPhaseMock = mock((_file: { fileRef: string; basename: string }) => {}); +const addToPbxProjectSectionMock = mock( + (_target: { uuid: string; pbxNativeTarget: Record }) => {}, +); +const getFirstTargetMock = mock(() => ({ uuid: 'APP_TARGET_UUID' })); +const findPBXGroupKeyMock = mock((_group: { name: string }) => 'GROUP_KEY'); +const addSourceFileMock = mock( + (_file: string, _options: { target: string }, _groupKey: string) => {}, +); +const addTargetDependencyMock = mock((_target: string, _dependencies: string[]) => {}); +const projectFactoryMock = mock((_pbxprojPath: string) => ({ + hash: { + project: { + objects: { + PBXNativeTarget: nativeTargetSection ?? {}, + }, + }, + }, + parseSync: parseSyncMock, + writeSync: writeSyncMock, + getBuildProperty: getBuildPropertyMock, + updateBuildProperty: updateBuildPropertyMock, + allUuids: allUuidsMock, + generateUuid: generateUuidMock, + addTarget: addTargetMock, + addXCConfigurationList: addXCConfigurationListMock, + addProductFile: addProductFileMock, + addToPbxBuildFileSection: addToPbxBuildFileSectionMock, + addToPbxNativeTargetSection: addToPbxNativeTargetSectionMock, + addBuildPhase: addBuildPhaseMock, + addToPbxCopyfilesBuildPhase: addToPbxCopyfilesBuildPhaseMock, + addToPbxProjectSection: addToPbxProjectSectionMock, + getFirstTarget: getFirstTargetMock, + findPBXGroupKey: findPBXGroupKeyMock, + addSourceFile: addSourceFileMock, + addTargetDependency: addTargetDependencyMock, + pbxNativeTargetSection: () => nativeTargetSection, + pbxTargetByName: (_targetName: string) => ({}), +})); + +mock.module('xcode', () => ({ + default: { + project: projectFactoryMock, + }, +})); + +import { + addNotificationServiceExtension, + ensureMainTargetEntitlementsLink, +} from '../pbxproj-modifier'; + +describe('ensureMainTargetEntitlementsLink', () => { + let tempDir: string; + let projectPath: string; + let pbxprojPath: string; + let entitlementsPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clix-pbxproj-test-')); + projectPath = path.join(tempDir, 'MyApp.xcodeproj'); + pbxprojPath = path.join(projectPath, 'project.pbxproj'); + entitlementsPath = path.join(tempDir, 'MyApp', 'MyApp.entitlements'); + + fs.mkdirSync(projectPath, { recursive: true }); + fs.mkdirSync(path.dirname(entitlementsPath), { recursive: true }); + fs.writeFileSync(pbxprojPath, '// original pbxproj', 'utf-8'); + fs.writeFileSync(entitlementsPath, '', 'utf-8'); + + nativeTargetSection = { + APP_TARGET: { + productType: 'com.apple.product-type.application', + name: 'MyApp', + }, + }; + currentCodeSignEntitlements = undefined; + strictAllUuidsMode = false; + strictProductTypeMode = false; + strictBuildFileSectionMode = false; + strictFileReferenceMode = false; + strictFallbackBuildPhaseMode = false; + + parseSyncMock.mockClear(); + writeSyncMock.mockClear(); + getBuildPropertyMock.mockClear(); + updateBuildPropertyMock.mockClear(); + allUuidsMock.mockClear(); + generateUuidMock.mockClear(); + addTargetMock.mockClear(); + addXCConfigurationListMock.mockClear(); + addProductFileMock.mockClear(); + addToPbxBuildFileSectionMock.mockClear(); + addToPbxNativeTargetSectionMock.mockClear(); + addBuildPhaseMock.mockClear(); + addToPbxCopyfilesBuildPhaseMock.mockClear(); + addToPbxProjectSectionMock.mockClear(); + getFirstTargetMock.mockClear(); + findPBXGroupKeyMock.mockClear(); + addSourceFileMock.mockClear(); + addTargetDependencyMock.mockClear(); + projectFactoryMock.mockClear(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('updates CODE_SIGN_ENTITLEMENTS for main target build configs', async () => { + const result = await ensureMainTargetEntitlementsLink({ + projectPath, + entitlementsPath, + }); + + const expectedRelativePath = ['MyApp', 'MyApp.entitlements'].join('/'); + + expect(result.success).toBe(true); + expect(result.alreadyConfigured).toBe(false); + expect(result.relativeEntitlementsPath).toBe(expectedRelativePath); + expect(result.targetName).toBe('MyApp'); + expect(updateBuildPropertyMock).toHaveBeenCalledTimes(1); + expect(updateBuildPropertyMock).toHaveBeenCalledWith( + 'CODE_SIGN_ENTITLEMENTS', + expectedRelativePath, + undefined, + 'MyApp', + ); + expect(writeSyncMock).toHaveBeenCalledTimes(1); + expect(currentCodeSignEntitlements).toBe(expectedRelativePath); + expect(fs.readFileSync(pbxprojPath, 'utf-8')).toBe('// updated pbxproj'); + }); + + test('detects main app target when productType is quoted in pbxproj', async () => { + nativeTargetSection = { + APP_TARGET: { + productType: `"com.apple.product-type.application"`, + name: 'MyApp', + }, + }; + + const result = await ensureMainTargetEntitlementsLink({ + projectPath, + entitlementsPath, + }); + + expect(result.success).toBe(true); + expect(result.targetName).toBe('MyApp'); + expect(updateBuildPropertyMock).toHaveBeenCalledTimes(1); + }); + + test('falls back to preferred target name when productType is missing', async () => { + nativeTargetSection = { + APP_TARGET: { + name: 'MyApp', + }, + EXTENSION_TARGET: { + name: 'MyAppNotificationServiceExtension', + productType: 'com.apple.product-type.app-extension', + }, + }; + + const result = await ensureMainTargetEntitlementsLink({ + projectPath, + entitlementsPath, + }); + + expect(result.success).toBe(true); + expect(result.targetName).toBe('MyApp'); + expect(updateBuildPropertyMock).toHaveBeenCalledTimes(1); + }); + + test('returns alreadyConfigured when entitlements path is already linked', async () => { + const expectedRelativePath = ['MyApp', 'MyApp.entitlements'].join('/'); + currentCodeSignEntitlements = `"${expectedRelativePath}"`; + + const result = await ensureMainTargetEntitlementsLink({ + projectPath, + entitlementsPath, + }); + + expect(result.success).toBe(true); + expect(result.alreadyConfigured).toBe(true); + expect(updateBuildPropertyMock).toHaveBeenCalledTimes(0); + expect(writeSyncMock).toHaveBeenCalledTimes(0); + }); + + test('fails when main app target cannot be found', async () => { + nativeTargetSection = { + EXTENSION_TARGET: { + productType: 'com.apple.product-type.app-extension', + name: 'MyAppNotificationServiceExtension', + buildConfigurationList: 'CONFIG_LIST /* Build configuration list */', + }, + }; + + const result = await ensureMainTargetEntitlementsLink({ + projectPath, + entitlementsPath, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('main application target'); + expect(writeSyncMock).toHaveBeenCalledTimes(0); + }); +}); + +describe('addNotificationServiceExtension', () => { + let tempDir: string; + let projectPath: string; + let pbxprojPath: string; + let extensionDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clix-nse-pbxproj-test-')); + projectPath = path.join(tempDir, 'MyApp.xcodeproj'); + pbxprojPath = path.join(projectPath, 'project.pbxproj'); + extensionDir = path.join(tempDir, 'MyAppNotificationServiceExtension'); + + fs.mkdirSync(projectPath, { recursive: true }); + fs.mkdirSync(extensionDir, { recursive: true }); + fs.writeFileSync(pbxprojPath, '// original pbxproj', 'utf-8'); + fs.writeFileSync(path.join(extensionDir, 'NotificationService.swift'), '// swift', 'utf-8'); + + nativeTargetSection = { + APP_TARGET: { + productType: 'com.apple.product-type.application', + name: 'MyApp', + dependencies: [], + }, + }; + strictAllUuidsMode = true; + strictProductTypeMode = false; + strictBuildFileSectionMode = false; + strictFileReferenceMode = false; + strictFallbackBuildPhaseMode = false; + + parseSyncMock.mockClear(); + writeSyncMock.mockClear(); + getBuildPropertyMock.mockClear(); + updateBuildPropertyMock.mockClear(); + allUuidsMock.mockClear(); + generateUuidMock.mockClear(); + addTargetMock.mockClear(); + addXCConfigurationListMock.mockClear(); + addProductFileMock.mockClear(); + addToPbxBuildFileSectionMock.mockClear(); + addToPbxNativeTargetSectionMock.mockClear(); + addBuildPhaseMock.mockClear(); + addToPbxCopyfilesBuildPhaseMock.mockClear(); + addToPbxProjectSectionMock.mockClear(); + getFirstTargetMock.mockClear(); + findPBXGroupKeyMock.mockClear(); + addSourceFileMock.mockClear(); + addTargetDependencyMock.mockClear(); + projectFactoryMock.mockClear(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('uses app_extension target type and avoids strict-mode allUuids crash', async () => { + const result = await addNotificationServiceExtension({ + projectPath, + extensionName: 'MyAppNotificationServiceExtension', + extensionBundleId: 'com.example.MyApp.MyAppNotificationServiceExtension', + extensionDir, + appGroupId: 'group.com.example.MyApp', + }); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.targetAdded).toBe(true); + expect(addTargetMock).toHaveBeenCalledTimes(1); + expect(addTargetMock.mock.calls[0]?.[1]).toBe('app_extension'); + expect(generateUuidMock).toHaveBeenCalledTimes(1); + expect(allUuidsMock).toHaveBeenCalledTimes(0); + expect(writeSyncMock).toHaveBeenCalledTimes(1); + }); + + test('falls back when xcode addTarget hits strict-mode PRODUCTTYPE reference error', async () => { + strictProductTypeMode = true; + + const result = await addNotificationServiceExtension({ + projectPath, + extensionName: 'MyAppNotificationServiceExtension', + extensionBundleId: 'com.example.MyApp.MyAppNotificationServiceExtension', + extensionDir, + appGroupId: 'group.com.example.MyApp', + }); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.targetAdded).toBe(true); + expect(addTargetMock).toHaveBeenCalledTimes(1); + expect(addXCConfigurationListMock).toHaveBeenCalledTimes(1); + expect(addProductFileMock).toHaveBeenCalledTimes(1); + expect(addToPbxNativeTargetSectionMock).toHaveBeenCalledTimes(1); + expect(result.warnings.some((warning) => warning.includes('strict-mode fallback'))).toBe(true); + }); + + test('falls back when xcode addTarget fails with pbxBuildFileSection error', async () => { + strictBuildFileSectionMode = true; + + const result = await addNotificationServiceExtension({ + projectPath, + extensionName: 'MyAppNotificationServiceExtension', + extensionBundleId: 'com.example.MyApp.MyAppNotificationServiceExtension', + extensionDir, + appGroupId: 'group.com.example.MyApp', + }); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.targetAdded).toBe(true); + expect(addTargetMock).toHaveBeenCalledTimes(1); + expect(addXCConfigurationListMock).toHaveBeenCalledTimes(1); + expect(addProductFileMock).toHaveBeenCalledTimes(1); + expect(addToPbxNativeTargetSectionMock).toHaveBeenCalledTimes(1); + expect(result.warnings.some((warning) => warning.includes('strict-mode fallback'))).toBe(true); + }); + + test('falls back when xcode addTarget fails with fileReference strict-mode error', async () => { + strictFileReferenceMode = true; + + const result = await addNotificationServiceExtension({ + projectPath, + extensionName: 'MyAppNotificationServiceExtension', + extensionBundleId: 'com.example.MyApp.MyAppNotificationServiceExtension', + extensionDir, + appGroupId: 'group.com.example.MyApp', + }); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.targetAdded).toBe(true); + expect(addTargetMock).toHaveBeenCalledTimes(1); + expect(addXCConfigurationListMock).toHaveBeenCalledTimes(1); + expect(result.warnings.some((warning) => warning.includes('strict-mode fallback'))).toBe(true); + }); + + test('keeps fallback success when embed phase creation hits fileReference strict-mode error', async () => { + strictFileReferenceMode = true; + strictFallbackBuildPhaseMode = true; + + const result = await addNotificationServiceExtension({ + projectPath, + extensionName: 'MyAppNotificationServiceExtension', + extensionBundleId: 'com.example.MyApp.MyAppNotificationServiceExtension', + extensionDir, + appGroupId: 'group.com.example.MyApp', + }); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.targetAdded).toBe(true); + expect(addBuildPhaseMock).toHaveBeenCalledTimes(1); + expect( + result.warnings.some((warning) => warning.includes('could not auto-create embed phase')), + ).toBe(true); + }); +}); diff --git a/src/lib/ios/extension-generator.ts b/src/lib/ios/extension-generator.ts index dcd667c..dc65c94 100644 --- a/src/lib/ios/extension-generator.ts +++ b/src/lib/ios/extension-generator.ts @@ -16,16 +16,29 @@ export interface ExtensionContext { bundleId: string; iosDir: string; pushEnvironment?: 'development' | 'production'; + projectId?: string; } export interface ExtensionGeneratorResult { success: boolean; createdFiles: string[]; + modifiedFiles: string[]; extensionDir: string; extensionName: string; + warnings: string[]; error?: string; } +export interface NotificationServiceStatus { + exists: boolean; + path: string; + importsClix: boolean; + inheritsClixNse: boolean; + hasRegisterCall: boolean; + hasSuperDidReceive: boolean; + registeredProjectId: string | null; +} + /** * Get the extension name from app name */ @@ -40,17 +53,132 @@ export function getExtensionBundleId(bundleId: string, appName: string): string return `${bundleId}.${getExtensionName(appName)}`; } +export function getNotificationServiceSwiftPath(iosDir: string, appName: string): string { + const extensionName = getExtensionName(appName); + return path.join(iosDir, extensionName, 'NotificationService.swift'); +} + /** * Check if extension files already exist */ export function extensionFilesExist(iosDir: string, appName: string): boolean { - const extensionName = getExtensionName(appName); - const extensionDir = path.join(iosDir, extensionName); - const swiftPath = path.join(extensionDir, 'NotificationService.swift'); + const swiftPath = getNotificationServiceSwiftPath(iosDir, appName); return fs.existsSync(swiftPath); } +function getRegisterProjectId(content: string): string | null { + const match = content.match(/register\s*\(\s*projectId:\s*"([^"]+)"\s*\)/); + return match?.[1] ?? null; +} + +function hasClixInheritance(content: string): boolean { + return /class\s+NotificationService\s*:\s*ClixNotificationServiceExtension/.test(content); +} + +function hasSuperDidReceiveCall(content: string): boolean { + return /super\.didReceive\s*\(\s*request\s*,\s*withContentHandler:\s*contentHandler\s*\)/.test( + content, + ); +} + +export function inspectNotificationServiceSwift( + iosDir: string, + appName: string, +): NotificationServiceStatus { + const swiftPath = getNotificationServiceSwiftPath(iosDir, appName); + if (!fs.existsSync(swiftPath)) { + return { + exists: false, + path: swiftPath, + importsClix: false, + inheritsClixNse: false, + hasRegisterCall: false, + hasSuperDidReceive: false, + registeredProjectId: null, + }; + } + + const content = fs.readFileSync(swiftPath, 'utf-8'); + const registeredProjectId = getRegisterProjectId(content); + + return { + exists: true, + path: swiftPath, + importsClix: /import\s+Clix/.test(content), + inheritsClixNse: hasClixInheritance(content), + hasRegisterCall: registeredProjectId !== null, + hasSuperDidReceive: hasSuperDidReceiveCall(content), + registeredProjectId, + }; +} + +export function ensureNotificationServiceSwiftProjectId( + iosDir: string, + appName: string, + projectId: string, +): { changed: boolean; path: string; warnings: string[] } { + const status = inspectNotificationServiceSwift(iosDir, appName); + const warnings: string[] = []; + + if (!status.exists) { + return { changed: false, path: status.path, warnings: ['NotificationService.swift not found'] }; + } + + let content = fs.readFileSync(status.path, 'utf-8'); + const original = content; + + if (!status.importsClix && content.includes('import UserNotifications')) { + content = content.replace( + /import UserNotifications\s*/, + 'import UserNotifications\nimport Clix\n', + ); + } + + if (!hasClixInheritance(content)) { + warnings.push('NotificationService class does not inherit ClixNotificationServiceExtension'); + } + + if (/register\s*\(\s*projectId:\s*"[^"]*"\s*\)/.test(content)) { + content = content.replace( + /register\s*\(\s*projectId:\s*"[^"]*"\s*\)/, + `register(projectId: "${projectId}")`, + ); + } else if (/override\s+init\s*\(\s*\)\s*\{/.test(content)) { + content = content.replace( + /override\s+init\s*\(\s*\)\s*\{\s*([\s\S]*?)\s*\}/, + (match, body: string) => { + if (/register\s*\(\s*projectId:/.test(body)) { + return match; + } + if (/super\.init\s*\(\s*\)/.test(body)) { + return match.replace( + /super\.init\s*\(\s*\)/, + `super.init()\n register(projectId: "${projectId}")`, + ); + } + return `override init() {\n super.init()\n register(projectId: "${projectId}")\n }`; + }, + ); + } else { + warnings.push('Could not find override init() to inject register(projectId:)'); + } + + if (!hasSuperDidReceiveCall(content)) { + warnings.push('didReceive does not call super.didReceive(request, withContentHandler:)'); + } + + if (content !== original) { + fs.writeFileSync(status.path, content, 'utf-8'); + } + + return { + changed: content !== original, + path: status.path, + warnings, + }; +} + /** * Create Notification Service Extension files */ @@ -60,6 +188,8 @@ export async function createExtensionFiles( const extensionName = getExtensionName(context.appName); const extensionDir = path.join(context.iosDir, extensionName); const createdFiles: string[] = []; + const modifiedFiles: string[] = []; + const warnings: string[] = []; const pushEnvironment = context.pushEnvironment || 'development'; try { @@ -75,6 +205,18 @@ export async function createExtensionFiles( createdFiles.push(swiftPath); } + if (context.projectId) { + const patchResult = ensureNotificationServiceSwiftProjectId( + context.iosDir, + context.appName, + context.projectId, + ); + if (patchResult.changed) { + modifiedFiles.push(patchResult.path); + } + warnings.push(...patchResult.warnings); + } + // 3. Create Info.plist const plistPath = path.join(extensionDir, 'Info.plist'); if (!fs.existsSync(plistPath)) { @@ -93,16 +235,20 @@ export async function createExtensionFiles( return { success: true, createdFiles, + modifiedFiles, extensionDir, extensionName, + warnings, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, createdFiles, + modifiedFiles, extensionDir, extensionName, + warnings, error: message, }; } diff --git a/src/lib/ios/index.ts b/src/lib/ios/index.ts index b378bd7..b463544 100644 --- a/src/lib/ios/index.ts +++ b/src/lib/ios/index.ts @@ -54,9 +54,13 @@ export { createExtensionFiles, type ExtensionContext, type ExtensionGeneratorResult, + ensureNotificationServiceSwiftProjectId, extensionFilesExist, getExtensionBundleId, getExtensionName, + getNotificationServiceSwiftPath, + inspectNotificationServiceSwift, + type NotificationServiceStatus, verifyExtensionFiles, } from './extension-generator'; export { generatePodfileSnippet } from './extension-templates'; @@ -74,8 +78,12 @@ export { export { addNotificationServiceExtension, backupProject, + ensureMainTargetEntitlementsLink, + getNotificationServiceExtensionStatus, getProjectTargets, hasNotificationServiceExtension, + type MainTargetEntitlementsLinkResult, + type NotificationServiceExtensionStatus, type PbxprojModificationResult, type PbxprojModifierOptions, restoreProject, @@ -85,6 +93,7 @@ export { addClixToExtensionTarget, backupPodfile, getPodfileTargets, + hasClixPodInExtensionTarget, hasExtensionTarget, hasPodfile, type PodfileModificationResult, @@ -103,6 +112,7 @@ export { APPLE_KEYS_TOO_MANY_ERROR, createPushKeyAsync, downloadPushKeyAsync, + isAppleKeysTooManyErrorMessage, isPushKeyValid, listPushKeysAsync, type PushKey, diff --git a/src/lib/ios/pbxproj-modifier.ts b/src/lib/ios/pbxproj-modifier.ts index aa6a896..fc48b5d 100644 --- a/src/lib/ios/pbxproj-modifier.ts +++ b/src/lib/ios/pbxproj-modifier.ts @@ -9,8 +9,350 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import xcode from 'xcode'; -const NSE_PRODUCT_TYPE = 'com.apple.product-type.app-extension'; +const NSE_TARGET_TYPE = 'app_extension'; const APP_PRODUCT_TYPE = 'com.apple.product-type.application'; +const APP_EXTENSION_PRODUCT_TYPE = 'com.apple.product-type.app-extension'; +const PRODUCT_TYPE_BY_TARGET_TYPE: Record = { + app_extension: APP_EXTENSION_PRODUCT_TYPE, +}; +const FILE_TYPE_BY_PRODUCT_TYPE: Record = { + [APP_EXTENSION_PRODUCT_TYPE]: '"wrapper.app-extension"', +}; + +interface TargetDescriptor { + uuid: string; + name: string; + productType: string; +} + +interface XcodeProjectWithAllUuids { + hash?: { + project?: { + objects?: Record | undefined>; + }; + }; + allUuids?: () => string[]; +} + +const NSE_REQUIRED_PBX_SECTIONS = [ + 'PBXBuildFile', + 'PBXFileReference', + 'PBXCopyFilesBuildPhase', + 'PBXTargetDependency', + 'PBXContainerItemProxy', +] as const; + +interface XcodeProjectWithStrictSafeTargetApis { + generateUuid?: () => string; + addXCConfigurationList?: ( + buildConfigurations: Array<{ + name: string; + isa: string; + buildSettings: Record; + }>, + defaultConfigurationName: string, + comment: string, + ) => { uuid?: string } | null; + addProductFile?: ( + productName: string, + options: { + group: string; + target: string; + explicitFileType: string; + }, + ) => { uuid?: string; fileRef?: string; basename?: string; target?: string } | null; + addToPbxBuildFileSection?: (file: { uuid: string; fileRef: string; basename: string }) => void; + addToPbxNativeTargetSection?: (target: { + uuid: string; + pbxNativeTarget: { + isa: string; + name: string; + productName: string; + productReference: string; + productType: string; + buildConfigurationList: string; + buildPhases: string[]; + buildRules: string[]; + dependencies: string[]; + }; + }) => void; + getFirstTarget?: () => { uuid?: string } | null; + addBuildPhase?: ( + files: string[], + buildPhaseType: string, + comment: string, + targetUuid: string, + targetType?: string, + ) => void; + addToPbxCopyfilesBuildPhase?: (file: { + uuid: string; + fileRef: string; + basename: string; + target?: string; + }) => void; + addToPbxProjectSection?: (target: { + uuid: string; + pbxNativeTarget: { + isa: string; + name: string; + productName: string; + productReference: string; + productType: string; + buildConfigurationList: string; + buildPhases: string[]; + buildRules: string[]; + dependencies: string[]; + }; + }) => void; + addTargetDependency?: (targetUuid: string, dependencyUuids: string[]) => void; +} + +/** + * `xcode` package has strict-mode issues in `allUuids()` (`for (key in ...)`). + * Patch the instance method to a strict-safe implementation before UUID generation APIs run. + */ +function patchXcodeAllUuidsForStrictMode(project: XcodeProjectWithAllUuids): void { + if (typeof project.allUuids !== 'function') { + return; + } + + project.allUuids = () => { + const sections = project.hash?.project?.objects; + if (!sections) { + return []; + } + + const uuids: string[] = []; + for (const section of Object.values(sections)) { + if (!section || typeof section !== 'object') { + continue; + } + + for (const sectionKey of Object.keys(section)) { + if (sectionKey.length === 24 && !sectionKey.endsWith('_comment')) { + uuids.push(sectionKey); + } + } + } + + return uuids; + }; +} + +function ensureNseRequiredPbxSections(project: XcodeProjectWithAllUuids): void { + const sections = project.hash?.project?.objects; + if (!sections) { + return; + } + + for (const sectionName of NSE_REQUIRED_PBX_SECTIONS) { + if (!sections[sectionName] || typeof sections[sectionName] !== 'object') { + sections[sectionName] = {}; + } + } +} + +function createNotificationServiceTargetWithStrictSafeApis( + project: XcodeProjectWithStrictSafeTargetApis, + options: Pick, +): { + target: { uuid: string }; + warnings: string[]; +} { + if ( + !project.generateUuid || + !project.addXCConfigurationList || + !project.addProductFile || + !project.addToPbxBuildFileSection || + !project.addToPbxNativeTargetSection || + !project.addToPbxProjectSection + ) { + throw new Error('xcode project target creation APIs are unavailable'); + } + + const targetName = options.extensionName.trim(); + const targetSubfolder = options.extensionName; + const productType = PRODUCT_TYPE_BY_TARGET_TYPE[NSE_TARGET_TYPE]; + const productFileType = FILE_TYPE_BY_PRODUCT_TYPE[productType]; + if (!productType || !productFileType) { + throw new Error(`Unsupported target type: ${NSE_TARGET_TYPE}`); + } + + let buildConfigurationsList: Array<{ + name: string; + isa: string; + buildSettings: Record; + }> = [ + { + name: 'Debug', + isa: 'XCBuildConfiguration', + buildSettings: { + GCC_PREPROCESSOR_DEFINITIONS: ['"DEBUG=1"', '"$(inherited)"'], + INFOPLIST_FILE: `"${path.join(targetSubfolder, 'Info.plist')}"`, + LD_RUNPATH_SEARCH_PATHS: + '"$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"', + PRODUCT_NAME: `"${targetName}"`, + SKIP_INSTALL: 'YES', + }, + }, + { + name: 'Release', + isa: 'XCBuildConfiguration', + buildSettings: { + INFOPLIST_FILE: `"${path.join(targetSubfolder, 'Info.plist')}"`, + LD_RUNPATH_SEARCH_PATHS: + '"$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"', + PRODUCT_NAME: `"${targetName}"`, + SKIP_INSTALL: 'YES', + }, + }, + ]; + + if (options.extensionBundleId) { + buildConfigurationsList = buildConfigurationsList.map((buildConfiguration) => ({ + ...buildConfiguration, + buildSettings: { + ...buildConfiguration.buildSettings, + PRODUCT_BUNDLE_IDENTIFIER: `"${options.extensionBundleId}"`, + }, + })); + } + + const buildConfigurations = project.addXCConfigurationList( + buildConfigurationsList, + 'Release', + `Build configuration list for PBXNativeTarget "${targetName}"`, + ); + const targetBuildConfigurationList = buildConfigurations?.uuid; + if (!targetBuildConfigurationList) { + throw new Error('Failed to create build configuration list for target'); + } + + const targetId = project.generateUuid(); + const productFile = project.addProductFile(targetName, { + group: 'Copy Files', + target: targetId, + explicitFileType: productFileType, + }); + if (!productFile?.fileRef || !productFile.basename) { + throw new Error('Failed to create product file for target'); + } + const productFileRecord = { + uuid: productFile.uuid || project.generateUuid(), + fileRef: productFile.fileRef, + basename: productFile.basename, + target: targetId, + }; + + const target = { + uuid: targetId, + pbxNativeTarget: { + isa: 'PBXNativeTarget', + name: `"${targetName}"`, + productName: `"${targetName}"`, + productReference: productFile.fileRef, + productType: `"${productType}"`, + buildConfigurationList: targetBuildConfigurationList, + buildPhases: [], + buildRules: [], + dependencies: [], + }, + }; + + project.addToPbxBuildFileSection({ + uuid: productFileRecord.uuid, + fileRef: productFileRecord.fileRef, + basename: productFileRecord.basename, + }); + project.addToPbxNativeTargetSection(target); + project.addToPbxProjectSection(target); + + const warnings: string[] = []; + const firstTargetUuid = project.getFirstTarget?.()?.uuid; + if (!firstTargetUuid) { + warnings.push('Created NSE target with fallback path; verify extension embedding in Xcode'); + } else if (project.addBuildPhase && project.addToPbxCopyfilesBuildPhase) { + try { + project.addBuildPhase( + [], + 'PBXCopyFilesBuildPhase', + 'Copy Files', + firstTargetUuid, + NSE_TARGET_TYPE, + ); + project.addToPbxCopyfilesBuildPhase({ + uuid: productFileRecord.uuid, + fileRef: productFileRecord.fileRef, + basename: productFileRecord.basename, + target: firstTargetUuid, + }); + } catch { + warnings.push( + 'Created NSE target but could not auto-create embed phase; verify embedding in Xcode', + ); + } + } else { + warnings.push('Created NSE target with fallback path; verify extension embedding in Xcode'); + } + + if (firstTargetUuid && project.addTargetDependency) { + try { + project.addTargetDependency(firstTargetUuid, [targetId]); + } catch { + warnings.push( + 'Created NSE target but could not auto-add target dependency; verify target dependencies in Xcode', + ); + } + } + + return { target: { uuid: targetId }, warnings }; +} + +function createNotificationServiceTarget( + project: XcodeProjectWithStrictSafeTargetApis & { + addTarget: ( + name: string, + targetType: string, + subfolder: string, + bundleId: string, + ) => { uuid: string } | null; + }, + options: Pick, +): { + target: { uuid: string } | null; + warnings: string[]; +} { + try { + return { + target: project.addTarget( + options.extensionName, + NSE_TARGET_TYPE, + options.extensionName, + options.extensionBundleId, + ), + warnings: [], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isRecoverableAddTargetError = + errorMessage.includes('PRODUCTTYPE_BY_TARGETTYPE is not defined') || + errorMessage.includes('FILETYPE_BY_PRODUCTTYPE is not defined') || + errorMessage.includes('this.pbxBuildFileSection()') || + errorMessage.includes('fileReference is not defined'); + if (!isRecoverableAddTargetError) { + throw error; + } + + const fallbackResult = createNotificationServiceTargetWithStrictSafeApis(project, options); + return { + target: fallbackResult.target, + warnings: [ + 'Applied strict-mode fallback while creating NSE target', + ...fallbackResult.warnings, + ], + }; + } +} /** * Options for pbxproj modification operations. @@ -44,6 +386,24 @@ export interface PbxprojModificationResult { error?: string; } +export interface NotificationServiceExtensionStatus { + targetExists: boolean; + buildSettings: { + enableUserScriptSandboxingNo: boolean; + infoPlistConfigured: boolean; + codeSignEntitlementsConfigured: boolean; + }; +} + +export interface MainTargetEntitlementsLinkResult { + success: boolean; + alreadyConfigured: boolean; + projectFilePath: string; + relativeEntitlementsPath: string; + targetName?: string; + error?: string; +} + /** * Check if NSE target already exists in the project. */ @@ -58,14 +418,21 @@ export function hasNotificationServiceExtension( try { const project = xcode.project(pbxprojPath); + patchXcodeAllUuidsForStrictMode(project as unknown as XcodeProjectWithAllUuids); project.parseSync(); const targets = project.pbxNativeTargetSection(); if (!targets) return false; + const expectedName = normalizeTargetValue(extensionName); + for (const key of Object.keys(targets)) { + if (key.endsWith('_comment')) { + continue; + } const target = targets[key] as { name?: string } | undefined; - if (target && typeof target === 'object' && target.name === extensionName) { + const targetName = target ? normalizeTargetValue(target.name) : ''; + if (target && typeof target === 'object' && targetName === expectedName) { return true; } } @@ -93,24 +460,516 @@ export function restoreProject(backupPath: string, projectPath: string): void { fs.copyFileSync(backupPath, pbxprojPath); } +function normalizeUuid(raw: string | undefined): string | null { + if (!raw) return null; + return raw.replace(/\s*\/\*.*\*\/\s*/g, '').trim() || null; +} + +function normalizeTargetValue(raw: unknown): string { + if (typeof raw !== 'string') { + return ''; + } + + const withoutComments = raw.replace(/\s*\/\*.*\*\/\s*/g, '').trim(); + return withoutComments.replace(/^"+|"+$/g, '').trim(); +} + +function parseTargetDescriptors(project: { + pbxNativeTargetSection: () => Record | null; +}): TargetDescriptor[] { + const targets = project.pbxNativeTargetSection(); + if (!targets) { + return []; + } + + const descriptors: TargetDescriptor[] = []; + + for (const key of Object.keys(targets)) { + if (key.endsWith('_comment')) { + continue; + } + + const uuid = normalizeUuid(key); + if (!uuid) { + continue; + } + + const target = targets[key] as { name?: unknown; productType?: unknown } | undefined; + if (!target || typeof target !== 'object') { + continue; + } + + descriptors.push({ + uuid, + name: normalizeTargetValue(target.name), + productType: normalizeTargetValue(target.productType), + }); + } + + return descriptors; +} + +function isLikelyNonMainTarget(targetName: string): boolean { + const lower = targetName.toLowerCase(); + return ( + lower.includes('test') || + lower.includes('uitest') || + lower.includes('extension') || + lower.includes('widget') + ); +} + +function findTargetByPreferredName( + targets: TargetDescriptor[], + preferredTargetNames: string[], +): TargetDescriptor | null { + const normalizedPreferredNames = preferredTargetNames + .map((name) => normalizeTargetValue(name)) + .filter((name) => name.length > 0); + + for (const preferredName of normalizedPreferredNames) { + const matched = targets.find((target) => normalizeTargetValue(target.name) === preferredName); + if (matched) { + return matched; + } + } + + return null; +} + /** - * Find the main app target UUID by productType. + * Find the main app target UUID by productType with fallback heuristics. */ -function findAppTargetUuid(project: { - pbxNativeTargetSection: () => Record | null; -}): string | null { +function findAppTargetUuid( + project: { + pbxNativeTargetSection: () => Record | null; + }, + preferredTargetNames: string[] = [], +): string | null { + const descriptors = parseTargetDescriptors(project); + if (descriptors.length === 0) { + return null; + } + + const appTargets = descriptors.filter((target) => target.productType === APP_PRODUCT_TYPE); + if (appTargets.length > 0) { + if (appTargets.length === 1) { + return appTargets[0].uuid; + } + + const preferredAppTarget = findTargetByPreferredName(appTargets, preferredTargetNames); + if (preferredAppTarget) { + return preferredAppTarget.uuid; + } + + const nonTestAppTarget = appTargets.find((target) => !isLikelyNonMainTarget(target.name)); + return (nonTestAppTarget ?? appTargets[0]).uuid; + } + + const preferredTarget = findTargetByPreferredName(descriptors, preferredTargetNames); + if (preferredTarget && !isLikelyNonMainTarget(preferredTarget.name)) { + return preferredTarget.uuid; + } + + const nonAuxiliaryTarget = descriptors.find((target) => !isLikelyNonMainTarget(target.name)); + if (nonAuxiliaryTarget) { + return nonAuxiliaryTarget.uuid; + } + + if (descriptors.length === 1 && !isLikelyNonMainTarget(descriptors[0].name)) { + return descriptors[0].uuid; + } + + return null; +} + +function findTargetUuidByName( + project: { pbxNativeTargetSection: () => Record | null }, + extensionName: string, +): string | null { const targets = project.pbxNativeTargetSection(); - if (!targets) return null; + if (!targets) { + return null; + } + + const expectedName = normalizeTargetValue(extensionName); for (const key of Object.keys(targets)) { - const t = targets[key] as { productType?: string } | undefined; - if (t && typeof t === 'object' && t.productType === APP_PRODUCT_TYPE) { - return key; + if (key.endsWith('_comment')) { + continue; + } + const target = targets[key] as { name?: string } | undefined; + const targetName = target ? normalizeTargetValue(target.name) : ''; + if (target && typeof target === 'object' && targetName === expectedName) { + return normalizeUuid(key); } } return null; } +function getTargetNameCandidates( + project: { pbxNativeTargetSection: () => Record | null }, + targetUuid: string, +): string[] { + const targets = project.pbxNativeTargetSection(); + if (!targets) { + return []; + } + + const target = targets[targetUuid] as { name?: unknown } | undefined; + const commentName = targets[`${targetUuid}_comment`]; + const normalizedName = normalizeTargetValue(target?.name); + const normalizedComment = normalizeTargetValue(commentName); + + const candidates = [ + typeof commentName === 'string' ? commentName : '', + typeof target?.name === 'string' ? target.name : '', + normalizedComment, + normalizedName, + normalizedComment ? `"${normalizedComment}"` : '', + normalizedName ? `"${normalizedName}"` : '', + ]; + + return [...new Set(candidates.filter((candidate) => candidate.length > 0))]; +} + +interface BuildSettingsTargetProject { + pbxNativeTargetSection: () => Record | null; + pbxXCConfigurationList?: () => Record | null; + pbxXCConfigurationListSection?: () => Record | null; + pbxXCBuildConfigurationSection?: () => Record | null; + updateBuildProperty?: ( + property: string, + value: string, + build: string | null, + targetName?: string, + ) => void; +} + +function getTargetConfigurationUuids( + project: { + pbxXCConfigurationList?: () => Record | null; + pbxXCConfigurationListSection?: () => Record | null; + pbxNativeTargetSection: () => Record | null; + }, + targetUuid: string, +): string[] { + const nativeTargets = project.pbxNativeTargetSection(); + const target = nativeTargets?.[targetUuid] as { buildConfigurationList?: unknown } | undefined; + const configListUuid = normalizeUuid( + typeof target?.buildConfigurationList === 'string' ? target.buildConfigurationList : undefined, + ); + if (!configListUuid) { + return []; + } + + const configListSection = + project.pbxXCConfigurationList?.() ?? project.pbxXCConfigurationListSection?.(); + if (!configListSection) { + return []; + } + + const configList = configListSection[configListUuid] as + | { buildConfigurations?: Array } + | undefined; + const configRefs = configList?.buildConfigurations ?? []; + const configUuids: string[] = []; + for (const configRef of configRefs) { + const configUuid = + typeof configRef === 'string' ? normalizeUuid(configRef) : normalizeUuid(configRef.value); + if (configUuid) { + configUuids.push(configUuid); + } + } + + return configUuids; +} + +function applyBuildSettingsToConfigurationSection( + buildConfigSection: Record, + configUuids: string[], + buildSettings: Record, +): string[] { + const applied = new Set(); + for (const configUuid of configUuids) { + const config = buildConfigSection[configUuid] as + | { buildSettings?: Record } + | undefined; + if (!config) { + continue; + } + + const currentBuildSettings = + config.buildSettings && typeof config.buildSettings === 'object' ? config.buildSettings : {}; + for (const [setting, value] of Object.entries(buildSettings)) { + currentBuildSettings[setting] = value; + applied.add(setting); + } + config.buildSettings = currentBuildSettings; + } + + return Array.from(applied); +} + +function applyBuildSettingsWithTargetName( + project: Pick, + targetNameCandidates: string[], + buildSettings: Record, +): { applied: string[]; warnings: string[] } { + if (!project.updateBuildProperty) { + return { + applied: [], + warnings: ['Could not update build settings automatically'], + }; + } + + const applied = new Set(); + const warnings: string[] = []; + for (const [setting, value] of Object.entries(buildSettings)) { + let updated = false; + for (const targetName of targetNameCandidates) { + try { + project.updateBuildProperty(setting, value, null, targetName); + updated = true; + break; + } catch { + // Try the next candidate. + } + } + + if (updated) { + applied.add(setting); + } else { + warnings.push(`Could not set ${setting}, may need manual configuration`); + } + } + + return { + applied: Array.from(applied), + warnings, + }; +} + +function applyBuildSettingsToTarget( + project: BuildSettingsTargetProject, + targetUuid: string, + buildSettings: Record, +): { applied: string[]; warnings: string[] } { + const configUuids = getTargetConfigurationUuids(project, targetUuid); + const buildConfigSection = project.pbxXCBuildConfigurationSection?.(); + + if (configUuids.length > 0 && buildConfigSection) { + const applied = applyBuildSettingsToConfigurationSection( + buildConfigSection, + configUuids, + buildSettings, + ); + if (applied.length > 0) { + return { applied, warnings: [] }; + } + } + + const targetNameCandidates = getTargetNameCandidates(project, targetUuid); + return applyBuildSettingsWithTargetName(project, targetNameCandidates, buildSettings); +} + +export function getNotificationServiceExtensionStatus( + projectPath: string, + extensionName: string, +): NotificationServiceExtensionStatus { + const defaultStatus: NotificationServiceExtensionStatus = { + targetExists: false, + buildSettings: { + enableUserScriptSandboxingNo: false, + infoPlistConfigured: false, + codeSignEntitlementsConfigured: false, + }, + }; + + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return defaultStatus; + } + + try { + const project = xcode.project(pbxprojPath); + patchXcodeAllUuidsForStrictMode(project as unknown as XcodeProjectWithAllUuids); + project.parseSync(); + + const targetUuid = findTargetUuidByName(project, extensionName); + if (!targetUuid) { + return defaultStatus; + } + + const nativeTargets = project.pbxNativeTargetSection() as Record | null; + const target = nativeTargets?.[targetUuid] as { buildConfigurationList?: string } | undefined; + const configListUuid = normalizeUuid(target?.buildConfigurationList); + if (!configListUuid) { + return { + ...defaultStatus, + targetExists: true, + }; + } + + const configListAccessor = project as unknown as { + pbxXCConfigurationList?: () => Record | null; + pbxXCConfigurationListSection?: () => Record | null; + }; + const configLists = + configListAccessor.pbxXCConfigurationList?.() ?? + configListAccessor.pbxXCConfigurationListSection?.(); + const configList = configLists?.[configListUuid] as + | { buildConfigurations?: Array } + | undefined; + const configRefs = configList?.buildConfigurations ?? []; + + const buildConfigSection = ( + project as unknown as { + pbxXCBuildConfigurationSection?: () => Record | null; + } + ).pbxXCBuildConfigurationSection?.(); + + const sandboxMatches: boolean[] = []; + const infoPlistMatches: boolean[] = []; + const entitlementsMatches: boolean[] = []; + + for (const configRef of configRefs) { + const configUuid = + typeof configRef === 'string' ? normalizeUuid(configRef) : normalizeUuid(configRef.value); + if (!configUuid) { + continue; + } + + const config = buildConfigSection?.[configUuid] as + | { buildSettings?: Record } + | undefined; + const buildSettings = config?.buildSettings ?? {}; + + const sandbox = String(buildSettings.ENABLE_USER_SCRIPT_SANDBOXING || '').toUpperCase(); + sandboxMatches.push(sandbox === 'NO'); + + const infoPlist = String(buildSettings.INFOPLIST_FILE || ''); + infoPlistMatches.push(infoPlist.includes('Info.plist')); + + const entitlements = String(buildSettings.CODE_SIGN_ENTITLEMENTS || ''); + entitlementsMatches.push(entitlements.endsWith(`${extensionName}.entitlements`)); + } + + return { + targetExists: true, + buildSettings: { + enableUserScriptSandboxingNo: + sandboxMatches.length > 0 && sandboxMatches.every((value) => value), + infoPlistConfigured: + infoPlistMatches.length > 0 && infoPlistMatches.every((value) => value), + codeSignEntitlementsConfigured: + entitlementsMatches.length > 0 && entitlementsMatches.every((value) => value), + }, + }; + } catch { + return defaultStatus; + } +} + +/** + * Ensure main app target build configurations reference the entitlements file. + * This automates the Xcode "Signing & Capabilities" entitlements linkage step. + */ +export async function ensureMainTargetEntitlementsLink(options: { + projectPath: string; + entitlementsPath: string; +}): Promise { + const pbxprojPath = path.join(options.projectPath, 'project.pbxproj'); + const result: MainTargetEntitlementsLinkResult = { + success: false, + alreadyConfigured: false, + projectFilePath: pbxprojPath, + relativeEntitlementsPath: '', + }; + + if (!fs.existsSync(pbxprojPath)) { + result.error = `Project file not found: ${pbxprojPath}`; + return result; + } + + try { + const project = xcode.project(pbxprojPath); + patchXcodeAllUuidsForStrictMode(project as unknown as XcodeProjectWithAllUuids); + project.parseSync(); + + const appNameFromProject = path.basename(options.projectPath, '.xcodeproj'); + const appNameFromEntitlements = path.basename(options.entitlementsPath, '.entitlements'); + const appTargetUuid = findAppTargetUuid(project, [appNameFromEntitlements, appNameFromProject]); + if (!appTargetUuid) { + result.error = 'Could not find main application target in Xcode project'; + return result; + } + + const nativeTargets = project.pbxNativeTargetSection() as Record | null; + const appTarget = nativeTargets?.[appTargetUuid] as { name?: string } | undefined; + if (appTarget?.name) { + result.targetName = appTarget.name; + } + + if (!result.targetName) { + result.error = 'Could not resolve main application target name in Xcode project'; + return result; + } + + const projectDir = path.dirname(options.projectPath); + const relativeEntitlementsPath = path + .relative(projectDir, options.entitlementsPath) + .split(path.sep) + .join('/'); + result.relativeEntitlementsPath = relativeEntitlementsPath; + + const currentEntitlementsRaw = ( + project as unknown as { + getBuildProperty?: ( + property: string, + build?: string, + targetName?: string, + ) => string | undefined; + } + ).getBuildProperty?.('CODE_SIGN_ENTITLEMENTS', undefined, result.targetName); + + const currentEntitlements = String(currentEntitlementsRaw || '').replace(/^"|"$/g, ''); + if (currentEntitlements === relativeEntitlementsPath) { + result.alreadyConfigured = true; + result.success = true; + return result; + } + + const typedProject = project as unknown as { + updateBuildProperty?: ( + property: string, + value: string, + build?: string, + targetName?: string, + ) => void; + }; + + if (!typedProject.updateBuildProperty) { + result.error = 'xcode project updateBuildProperty API is unavailable'; + return result; + } + + typedProject.updateBuildProperty( + 'CODE_SIGN_ENTITLEMENTS', + relativeEntitlementsPath, + undefined, + result.targetName, + ); + + fs.writeFileSync(pbxprojPath, project.writeSync()); + result.success = true; + return result; + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + return result; + } +} + /** * Add Notification Service Extension target to Xcode project. */ @@ -135,40 +994,50 @@ export async function addNotificationServiceExtension( try { // 1. Parse project const project = xcode.project(pbxprojPath); + patchXcodeAllUuidsForStrictMode(project as unknown as XcodeProjectWithAllUuids); project.parseSync(); + ensureNseRequiredPbxSections(project as unknown as XcodeProjectWithAllUuids); - // 2. Check if target already exists - if (hasNotificationServiceExtension(options.projectPath, options.extensionName)) { - result.warnings.push('NSE target already exists, skipping target creation'); - result.success = true; - return result; - } + // 2. Ensure target exists (create only if missing) + let targetUuid = findTargetUuidByName(project, options.extensionName); + if (!targetUuid) { + const { target, warnings } = createNotificationServiceTarget( + project as unknown as XcodeProjectWithStrictSafeTargetApis & { + addTarget: ( + name: string, + targetType: string, + subfolder: string, + bundleId: string, + ) => { uuid: string } | null; + }, + { + extensionName: options.extensionName, + extensionBundleId: options.extensionBundleId, + }, + ); + result.warnings.push(...warnings); - // 3. Add NSE target - const target = project.addTarget( - options.extensionName, - NSE_PRODUCT_TYPE, - options.extensionName, - options.extensionBundleId, - ); - - if (!target) { - result.error = 'Failed to add target to project'; - return result; - } + if (!target) { + result.error = 'Failed to add target to project'; + return result; + } - result.targetAdded = true; + targetUuid = normalizeUuid(target.uuid); + result.targetAdded = true; - // 4. Add source file (NotificationService.swift) - const swiftFile = path.join(options.extensionDir, 'NotificationService.swift'); - if (fs.existsSync(swiftFile)) { - const groupKey = project.findPBXGroupKey({ name: options.extensionName }); - if (groupKey) { - project.addSourceFile('NotificationService.swift', { target: target.uuid }, groupKey); - result.filesLinked.push('NotificationService.swift'); - } else { - result.warnings.push('Could not find group for extension, files may need manual linking'); + // Add source file only on fresh target creation. + const swiftFile = path.join(options.extensionDir, 'NotificationService.swift'); + if (fs.existsSync(swiftFile)) { + const groupKey = project.findPBXGroupKey({ name: options.extensionName }); + if (groupKey) { + project.addSourceFile('NotificationService.swift', { target: target.uuid }, groupKey); + result.filesLinked.push('NotificationService.swift'); + } else { + result.warnings.push('Could not find group for extension, files may need manual linking'); + } } + } else { + result.warnings.push('NSE target already exists, applying missing settings only'); } // 5. Set build settings for the extension target @@ -185,7 +1054,7 @@ export async function addNotificationServiceExtension( PRODUCT_BUNDLE_IDENTIFIER: options.extensionBundleId, IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget, SWIFT_VERSION: '5.0', - TARGETED_DEVICE_FAMILY: '1,2', + TARGETED_DEVICE_FAMILY: '"1,2"', GENERATE_INFOPLIST_FILE: 'NO', }; @@ -193,29 +1062,30 @@ export async function addNotificationServiceExtension( buildSettings.DEVELOPMENT_TEAM = options.teamId; } - // Apply build settings to both Debug and Release configurations - for (const [setting, value] of Object.entries(buildSettings)) { - try { - project.updateBuildProperty(setting, value, null, options.extensionName); - result.buildSettingsApplied.push(setting); - } catch { - result.warnings.push(`Could not set ${setting}, may need manual configuration`); - } + if (!targetUuid) { + result.error = 'Failed to resolve NSE target UUID after creation'; + return result; } + const buildSettingsResult = applyBuildSettingsToTarget(project, targetUuid, buildSettings); + result.buildSettingsApplied.push(...buildSettingsResult.applied); + result.warnings.push(...buildSettingsResult.warnings); + // 6. Add target dependency to main app (embed extension) // Find the main app target by productType instead of using getFirstTarget() - const appTargetUuid = findAppTargetUuid(project); - if (appTargetUuid) { - try { - project.addTargetDependency(appTargetUuid, [target.uuid]); - } catch { - result.warnings.push( - 'Could not add target dependency, extension may need manual embedding', - ); + if (result.targetAdded) { + const appTargetUuid = findAppTargetUuid(project); + if (appTargetUuid && targetUuid) { + try { + project.addTargetDependency(appTargetUuid, [targetUuid]); + } catch { + result.warnings.push( + 'Could not add target dependency, extension may need manual embedding', + ); + } + } else { + result.warnings.push('Could not find main app target, extension may need manual embedding'); } - } else { - result.warnings.push('Could not find main app target, extension may need manual embedding'); } // 7. Write changes to project file @@ -240,6 +1110,7 @@ export function getProjectTargets(projectPath: string): string[] { try { const project = xcode.project(pbxprojPath); + patchXcodeAllUuidsForStrictMode(project as unknown as XcodeProjectWithAllUuids); project.parseSync(); const targets = project.pbxNativeTargetSection(); diff --git a/src/lib/ios/podfile-modifier.ts b/src/lib/ios/podfile-modifier.ts index 0dfaf46..5f4725c 100644 --- a/src/lib/ios/podfile-modifier.ts +++ b/src/lib/ios/podfile-modifier.ts @@ -28,6 +28,7 @@ export interface PodfileModificationResult { modified: boolean; podfileExists: boolean; targetAdded: boolean; + clixPodAdded: boolean; error?: string; } @@ -50,6 +51,33 @@ export function hasExtensionTarget(iosDir: string, extensionName: string): boole return targetRegex.test(content); } +function extractTargetBlock(content: string, targetName: string): string | null { + const targetRegex = new RegExp( + `target\\s+['"]${escapeRegex(targetName)}['"]\\s+do([\\s\\S]*?)\\nend`, + 'i', + ); + const match = content.match(targetRegex); + return match?.[0] ?? null; +} + +export function hasClixPodInExtensionTarget( + iosDir: string, + extensionName: string, + clixPodSpec = 'Clix', +): boolean { + const podfilePath = path.join(iosDir, 'Podfile'); + if (!fs.existsSync(podfilePath)) return false; + + const content = fs.readFileSync(podfilePath, 'utf-8'); + const targetBlock = extractTargetBlock(content, extensionName); + if (!targetBlock) { + return false; + } + + const podRegex = new RegExp(`pod\\s+['"]${escapeRegex(clixPodSpec)}['"]`, 'i'); + return podRegex.test(targetBlock); +} + /** * Create backup of Podfile before modification. */ @@ -79,6 +107,7 @@ export async function addClixToExtensionTarget( modified: false, podfileExists: false, targetAdded: false, + clixPodAdded: false, }; const podfilePath = path.join(options.iosDir, 'Podfile'); @@ -94,28 +123,41 @@ export async function addClixToExtensionTarget( try { let content = fs.readFileSync(podfilePath, 'utf-8'); - // Check if target already exists if (hasExtensionTarget(options.iosDir, options.extensionName)) { + if (hasClixPodInExtensionTarget(options.iosDir, options.extensionName, options.clixPodSpec)) { + result.success = true; + return result; + } + + const targetBlock = extractTargetBlock(content, options.extensionName); + if (!targetBlock) { + result.error = `Failed to locate target block for ${options.extensionName}`; + return result; + } + + const updatedBlock = targetBlock.replace( + /\nend$/i, + `\n pod '${options.clixPodSpec || 'Clix'}'\nend`, + ); + content = content.replace(targetBlock, updatedBlock); + + fs.writeFileSync(podfilePath, content); result.success = true; + result.modified = true; + result.clixPodAdded = true; return result; } - // Generate target block const targetBlock = generateTargetBlock(options.extensionName, options.clixPodSpec); - - // Find insertion point - // Strategy: Insert before post_install hook, or at the end of the file const postInstallMatch = content.match(/^post_install\s+do/m); if (postInstallMatch && postInstallMatch.index !== undefined) { - // Insert before post_install content = content.slice(0, postInstallMatch.index) + targetBlock + '\n\n' + content.slice(postInstallMatch.index); } else { - // Append at the end content = `${content.trimEnd()}\n\n${targetBlock}\n`; } @@ -124,6 +166,7 @@ export async function addClixToExtensionTarget( result.success = true; result.modified = true; result.targetAdded = true; + result.clixPodAdded = true; } catch (error) { result.error = error instanceof Error ? error.message : String(error); } diff --git a/src/lib/ios/push-key.ts b/src/lib/ios/push-key.ts index 744bb71..3bee8dd 100644 --- a/src/lib/ios/push-key.ts +++ b/src/lib/ios/push-key.ts @@ -45,6 +45,26 @@ Revoke the old ones or reuse existing from your other apps. Remember that Apple Keys are not application specific! `; +/** + * Detect Apple APNS key limit errors from raw portal messages. + */ +export function isAppleKeysTooManyErrorMessage(message: string): boolean { + const compact = message.toLowerCase().replace(/\s+/g, ' ').trim(); + if (!compact) { + return false; + } + + const hasMaxAllowed = compact.includes('maximum allowed number of'); + const hasKeyWord = compact.includes(' key') || compact.includes(' keys'); + + return ( + compact.includes('you can have only two apple keys') || + compact.includes('team scoped keys for this service') || + compact.includes('maxkeyscreatederror') || + (hasMaxAllowed && hasKeyWord) + ); +} + const { MaxKeysCreatedError } = Keys; /** @@ -123,12 +143,14 @@ export async function createPushKeyAsync( } catch (err: unknown) { const error = err as { rawDump?: { resultString?: string } }; const resultString = error.rawDump?.resultString; + const errorMessage = err instanceof Error ? err.message : ''; if ( err instanceof MaxKeysCreatedError || - (typeof resultString === 'string' && resultString.includes('maximum allowed number of Keys')) + (typeof resultString === 'string' && isAppleKeysTooManyErrorMessage(resultString)) || + isAppleKeysTooManyErrorMessage(errorMessage) ) { - throw new Error(APPLE_KEYS_TOO_MANY_ERROR); + throw new Error(APPLE_KEYS_TOO_MANY_ERROR.trim()); } throw err; } diff --git a/src/lib/push/constants.ts b/src/lib/push/constants.ts index ddbbb9a..c057d15 100644 --- a/src/lib/push/constants.ts +++ b/src/lib/push/constants.ts @@ -31,17 +31,18 @@ export const APNS_KEY_CREATION_STEPS = [ 'Click "Continue" then "Register"', 'Download the .p8 file (you can only download once!)', 'Note the Key ID shown on the page', - 'Copy the .p8 file to this project directory', + 'Copy the .p8 file to your project .clix directory', ] as const; /** * Firebase upload steps. */ export const FIREBASE_UPLOAD_STEPS = [ - 'Go to "iOS app configuration" section', - 'Click "Upload" under APNs Authentication Key', + 'Go to Project settings -> Your apps', + 'In Apple apps, select your iOS app', + 'Open the Cloud Messaging tab', + 'Under APNs Authentication Key, click Upload', 'Select your .p8 file', - 'Enter the Key ID', - 'Enter your Team ID', + 'Enter the Key ID and Team ID', 'Click "Upload"', ] as const; diff --git a/src/lib/services/firebase/__tests__/downloader.test.ts b/src/lib/services/firebase/__tests__/downloader.test.ts new file mode 100644 index 0000000..2036547 --- /dev/null +++ b/src/lib/services/firebase/__tests__/downloader.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { FirebaseDownloader } from '../downloader'; + +interface MutableFirebaseDownloader { + authClient: { + authenticate: ( + openBrowser: (url: string) => void, + ) => Promise<{ success: boolean; error?: string }>; + cancelAuthentication: (reason?: string) => void; + isAuthenticated: () => Promise; + }; +} + +describe('FirebaseDownloader.cancelAuthentication', () => { + test('delegates cancellation to GoogleAuthClient', () => { + const downloader = new FirebaseDownloader(); + const cancelAuthentication = mock((_reason?: string) => {}); + const authenticate = mock( + async (_openBrowser: (url: string) => void): Promise<{ success: boolean }> => ({ + success: true, + }), + ); + const isAuthenticated = mock(async (): Promise => false); + const mutableDownloader = downloader as unknown as MutableFirebaseDownloader; + + mutableDownloader.authClient = { + authenticate, + cancelAuthentication, + isAuthenticated, + }; + + downloader.cancelAuthentication('Task cancelled'); + + expect(cancelAuthentication).toHaveBeenCalledTimes(1); + expect(cancelAuthentication.mock.calls[0]?.[0]).toBe('Task cancelled'); + }); + + test('skips OAuth flow when already authenticated', async () => { + const downloader = new FirebaseDownloader(); + const authenticate = mock( + async (_openBrowser: (url: string) => void): Promise<{ success: boolean }> => ({ + success: true, + }), + ); + const isAuthenticated = mock(async (): Promise => true); + const mutableDownloader = downloader as unknown as MutableFirebaseDownloader; + const openBrowser = mock((_url: string) => {}); + + mutableDownloader.authClient = { + authenticate, + cancelAuthentication: mock((_reason?: string) => {}), + isAuthenticated, + }; + + const result = await downloader.authenticate(openBrowser); + + expect(result.success).toBe(true); + expect(isAuthenticated).toHaveBeenCalledTimes(1); + expect(authenticate).toHaveBeenCalledTimes(0); + expect(openBrowser).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index 6f03363..35e9e6a 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -97,6 +97,10 @@ export class FirebaseDownloader { async authenticate( openBrowser: (url: string) => void, ): Promise<{ success: boolean; error?: string }> { + if (await this.authClient.isAuthenticated()) { + return { success: true }; + } + const result = await this.authClient.authenticate(openBrowser); if (result.success) { // Fetch and store credentials for API clients @@ -112,6 +116,13 @@ export class FirebaseDownloader { return result; } + /** + * Cancel in-flight Firebase OAuth authentication. + */ + cancelAuthentication(reason = 'Firebase authentication cancelled'): void { + this.authClient.cancelAuthentication(reason); + } + /** * Ensure API client is initialized. */ diff --git a/src/lib/services/firebase/oauth/__tests__/auth-client.test.ts b/src/lib/services/firebase/oauth/__tests__/auth-client.test.ts new file mode 100644 index 0000000..9d58cca --- /dev/null +++ b/src/lib/services/firebase/oauth/__tests__/auth-client.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { GoogleAuthClient } from '../auth-client'; + +interface MutableGoogleAuthClient { + callbackServer: { cancel: (reason?: string) => void } | null; + codeVerifier: string | null; + oauthState: string | null; +} + +describe('GoogleAuthClient.cancelAuthentication', () => { + test('cancels callback server and clears OAuth runtime state', () => { + const client = new GoogleAuthClient(); + const cancel = mock((_reason?: string) => {}); + const mutableClient = client as unknown as MutableGoogleAuthClient; + + mutableClient.callbackServer = { cancel }; + mutableClient.codeVerifier = 'verifier'; + mutableClient.oauthState = 'state'; + + client.cancelAuthentication('User cancelled'); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel.mock.calls[0]?.[0]).toBe('User cancelled'); + expect(mutableClient.callbackServer).toBeNull(); + expect(mutableClient.codeVerifier).toBeNull(); + expect(mutableClient.oauthState).toBeNull(); + }); + + test('is safe when no callback server is active', () => { + const client = new GoogleAuthClient(); + expect(() => client.cancelAuthentication()).not.toThrow(); + }); +}); diff --git a/src/lib/services/firebase/oauth/auth-client.ts b/src/lib/services/firebase/oauth/auth-client.ts index abbac4e..55bb1aa 100644 --- a/src/lib/services/firebase/oauth/auth-client.ts +++ b/src/lib/services/firebase/oauth/auth-client.ts @@ -126,11 +126,10 @@ export class GoogleAuthClient { try { const result = await this.callbackServer.waitForCallback(); - this.oauthState = null; return result; - } catch (error) { + } finally { this.oauthState = null; - throw error; + this.callbackServer = null; } } @@ -407,6 +406,16 @@ export class GoogleAuthClient { } } + /** + * Cancel in-flight OAuth authentication. + */ + cancelAuthentication(reason = 'OAuth authentication cancelled'): void { + this.callbackServer?.cancel(reason); + this.callbackServer = null; + this.oauthState = null; + this.codeVerifier = null; + } + /** * Clear stored tokens (logout). */ diff --git a/src/lib/utils/__tests__/oauth-callback-server.test.ts b/src/lib/utils/__tests__/oauth-callback-server.test.ts new file mode 100644 index 0000000..d82346a --- /dev/null +++ b/src/lib/utils/__tests__/oauth-callback-server.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; +import { OAuthCallbackServer } from '../oauth'; + +describe('OAuthCallbackServer cancellation', () => { + test('rejects pending wait when cancel is called', async () => { + const server = new OAuthCallbackServer({ port: 0, timeoutMs: 10_000 }); + + try { + await server.start(); + const waitPromise = server.waitForCallback(); + server.cancel('Cancelled for test'); + await expect(waitPromise).rejects.toThrow('Cancelled for test'); + } finally { + server.stop(); + } + }); + + test('rejects concurrent waits while callback is already pending', async () => { + const server = new OAuthCallbackServer({ port: 0, timeoutMs: 10_000 }); + + try { + await server.start(); + const firstWait = server.waitForCallback(); + await expect(server.waitForCallback()).rejects.toThrow('OAuth callback already in progress'); + server.cancel('Cancelled for test'); + await expect(firstWait).rejects.toThrow('Cancelled for test'); + } finally { + server.stop(); + } + }); +}); diff --git a/src/lib/utils/oauth.ts b/src/lib/utils/oauth.ts index 6d8bdb7..4103cb2 100644 --- a/src/lib/utils/oauth.ts +++ b/src/lib/utils/oauth.ts @@ -5,7 +5,7 @@ */ import { createHash, randomBytes } from 'node:crypto'; -import { createServer, type Server } from 'node:http'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { oauthLogger } from '@/lib/debug/logger'; import { findProjectRoot } from './path'; @@ -190,6 +190,12 @@ export class OAuthCallbackServer { private server: Server | null = null; private options: Required; private actualPort: number = 0; + private pendingCallback: { + resolve: (result: OAuthCallbackResult) => void; + reject: (error: Error) => void; + timeout: ReturnType; + requestHandler: (req: IncomingMessage, res: ServerResponse) => void; + } | null = null; constructor(options: CallbackServerOptions = {}) { this.options = { @@ -239,12 +245,17 @@ export class OAuthCallbackServer { return; } + if (this.pendingCallback) { + reject(new Error('OAuth callback already in progress')); + return; + } + const timeout = setTimeout(() => { + this.rejectPendingCallback(new Error('OAuth callback timeout')); this.stop(); - reject(new Error('OAuth callback timeout')); }, this.options.timeoutMs); - this.server.on('request', (req, res) => { + const requestHandler = (req: IncomingMessage, res: ServerResponse) => { const url = new URL(req.url || '/', `http://localhost:${this.actualPort}`); if (url.pathname !== this.options.callbackPath) { @@ -277,9 +288,8 @@ export class OAuthCallbackServer { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.options.errorHtml(errorMsg)); - clearTimeout(timeout); + this.rejectPendingCallback(new Error(errorMsg)); this.stop(); - reject(new Error(errorMsg)); return; } @@ -287,9 +297,8 @@ export class OAuthCallbackServer { if (this.options.expectedState && state !== this.options.expectedState) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.options.errorHtml('Invalid OAuth state')); - clearTimeout(timeout); + this.rejectPendingCallback(new Error('OAuth state mismatch')); this.stop(); - reject(new Error('OAuth state mismatch')); return; } @@ -297,26 +306,42 @@ export class OAuthCallbackServer { if (!code) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.options.errorHtml('No authorization code received')); - clearTimeout(timeout); + this.rejectPendingCallback(new Error('No authorization code received')); this.stop(); - reject(new Error('No authorization code received')); return; } // Success res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.options.successHtml); - clearTimeout(timeout); + this.resolvePendingCallback({ code, state }); this.stop(); - resolve({ code, state }); - }); + }; + + this.pendingCallback = { + resolve, + reject: (error: Error) => reject(error), + timeout, + requestHandler, + }; + + this.server.on('request', requestHandler); }); } + /** + * Cancel waiting for OAuth callback. + */ + cancel(reason = 'OAuth authentication cancelled'): void { + this.rejectPendingCallback(new Error(reason)); + this.stop(); + } + /** * Stop the callback server. */ stop(): void { + this.clearPendingCallback(); if (this.server) { this.server.close(); this.server = null; @@ -329,4 +354,34 @@ export class OAuthCallbackServer { getPort(): number { return this.actualPort; } + + private clearPendingCallback(): void { + if (!this.pendingCallback) { + return; + } + + clearTimeout(this.pendingCallback.timeout); + this.server?.off('request', this.pendingCallback.requestHandler); + this.pendingCallback = null; + } + + private rejectPendingCallback(error: Error): void { + if (!this.pendingCallback) { + return; + } + + const { reject } = this.pendingCallback; + this.clearPendingCallback(); + reject(error); + } + + private resolvePendingCallback(result: OAuthCallbackResult): void { + if (!this.pendingCallback) { + return; + } + + const { resolve } = this.pendingCallback; + this.clearPendingCallback(); + resolve(result); + } } diff --git a/src/ui/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx index eb01aaa..f2eafdb 100644 --- a/src/ui/IosSetupUI.tsx +++ b/src/ui/IosSetupUI.tsx @@ -7,12 +7,14 @@ import { buildAgentContext, type CapabilitySyncResult, createAuthContext, + ensureMainTargetEntitlementsLink, generateAppGroupId, getAppleApiErrorMessage, getEntitlementsPath, getIosProjectDir, type IosProjectInfo, loadApiKeyFromFile, + type MainTargetEntitlementsLinkResult, readEntitlements, syncCapabilities, updateEntitlementsForClix, @@ -60,6 +62,14 @@ export interface IosSetupResult { firebaseProjectId?: string | null; /** Apple Team ID for push setup integration (from GoogleService-Info.plist) */ teamId?: string | null; + /** Structured summary for idempotent install flow */ + changeSummary?: { + changedFiles: string[]; + skippedChecks: string[]; + warnings: string[]; + }; + /** Main app target CODE_SIGN_ENTITLEMENTS linkage result */ + mainTargetEntitlementsLink?: MainTargetEntitlementsLinkResult; } interface IosSetupUIProps { @@ -71,6 +81,7 @@ interface SetupState { phase: SetupPhase; projectInfo: IosProjectInfo | null; portalResult: CapabilitySyncResult | null; + mainTargetEntitlementsLink: MainTargetEntitlementsLinkResult | null; updatedFiles: string[]; errorMessage: string; } @@ -164,6 +175,11 @@ async function runSetup( const result: IosSetupResult = { success: false, entitlementsUpdated: [], + changeSummary: { + changedFiles: [], + skippedChecks: [], + warnings: [], + }, }; // Phase 1: Analyze iOS project @@ -189,6 +205,9 @@ async function runSetup( setState((s) => ({ ...s, portalResult: syncResult })); result.portalSync = syncResult; } + if (options.skipPortal) { + result.changeSummary?.skippedChecks.push('Apple Developer Portal sync'); + } // Phase 3: Update local entitlements files setState((s) => ({ ...s, phase: 'updating_entitlements' })); @@ -203,6 +222,27 @@ async function runSetup( const files = entitlementsResult.files; setState((s) => ({ ...s, updatedFiles: files })); result.entitlementsUpdated = files; + result.changeSummary?.changedFiles.push(...files); + + // Phase 4: Ensure main app target links the entitlements file in build settings. + const mainTargetLinkResult = await ensureMainTargetEntitlementsLink({ + projectPath: project.projectPath, + entitlementsPath: entitlementsResult.entitlementsPath, + }); + setState((s) => ({ ...s, mainTargetEntitlementsLink: mainTargetLinkResult })); + result.mainTargetEntitlementsLink = mainTargetLinkResult; + + if (mainTargetLinkResult.success && !mainTargetLinkResult.alreadyConfigured) { + const pbxprojPath = mainTargetLinkResult.projectFilePath; + if (!result.changeSummary?.changedFiles.includes(pbxprojPath)) { + result.changeSummary?.changedFiles.push(pbxprojPath); + } + } + + if (!mainTargetLinkResult.success) { + const reason = mainTargetLinkResult.error || 'unknown reason'; + result.changeSummary?.warnings.push(`Failed to update Xcode entitlements linkage: ${reason}`); + } // Build agent context for remaining tasks result.agentContext = buildAgentContext( @@ -246,12 +286,20 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = phase: 'analyzing', projectInfo: null, portalResult: null, + mainTargetEntitlementsLink: null, updatedFiles: [], errorMessage: '', }); const [result, setResult] = useState(null); - const { phase, projectInfo, portalResult, updatedFiles, errorMessage } = state; + const { + phase, + projectInfo, + portalResult, + mainTargetEntitlementsLink, + updatedFiles, + errorMessage, + } = state; // Handle user input for complete/error phases const handleContinue = useCallback(() => { @@ -322,6 +370,7 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = @@ -381,58 +430,120 @@ const PortalSyncStatus: React.FC<{ const CompletePhase: React.FC<{ projectInfo: IosProjectInfo | null; portalResult: CapabilitySyncResult | null; + mainTargetEntitlementsLink: MainTargetEntitlementsLinkResult | 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} - - )} - - )} +}> = ({ projectInfo, portalResult, mainTargetEntitlementsLink, updatedFiles, skipPortal }) => { + const nextSteps: string[] = []; - {skipPortal && ( - - )} + if (mainTargetEntitlementsLink?.success) { + nextSteps.push( + 'Xcode: Open ios/*.xcworkspace, select the main app target, and verify Push Notifications/App Groups in Signing & Capabilities.', + ); + nextSteps.push('Xcode: Product -> Clean Build Folder, then Product -> Build.'); + } else { + nextSteps.push('Xcode: Open ios/*.xcworkspace and select the main app target.'); + nextSteps.push( + 'Xcode: Build Settings -> set Code Signing Entitlements to the main app .entitlements file.', + ); + nextSteps.push( + 'Xcode: Signing & Capabilities -> + Capability -> enable Push Notifications and App Groups.', + ); + nextSteps.push( + 'Xcode: Product -> Clean Build Folder, then Product -> Build and resolve remaining signing/profile errors.', + ); + } - - {updatedFiles.map((file) => ( - - • {file} - - ))} + if (skipPortal) { + nextSteps.push( + 'If Xcode cannot enable capabilities (team/permission issue), use Apple Developer Portal: https://developer.apple.com/account/resources/identifiers/list -> Identifiers -> your App ID -> enable Push Notifications/App Groups -> Save.', + ); + nextSteps.push( + 'If signing/profile errors remain, open https://developer.apple.com/account/resources/profiles/list and regenerate profiles. With Automatic Signing, Xcode may refresh them automatically.', + ); + } else { + nextSteps.push( + 'If signing/profile errors remain, open https://developer.apple.com/account/resources/profiles/list and regenerate profiles for this App ID (or let Automatic Signing refresh them).', + ); + } - - - ✓ iOS setup completed successfully! - - + return ( + + + + {portalResult && ( + + + {portalResult.enabled.length > 0 && ( + + Enabled: {portalResult.enabled.join(', ')} + + )} + {portalResult.appGroupCreated && portalResult.appGroupId && ( + + Created App Group: {portalResult.appGroupId} + + )} + + )} + + {skipPortal && ( + + )} + + + {updatedFiles.map((file) => ( + + • {file} + + ))} + + {mainTargetEntitlementsLink?.success ? ( + + + + ) : ( + + + {mainTargetEntitlementsLink?.error && ( + + Reason: {mainTargetEntitlementsLink.error} + + )} + + )} - {/* 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} + + + ✓ iOS setup completed successfully! + - - - Press Enter to continue + {/* Next steps */} + + Next steps: + + {nextSteps.map((step, index) => ( + + {index + 1}. {step} + + ))} + + + + + Press Enter to continue + - -); + ); +}; diff --git a/src/ui/LoginUI.tsx b/src/ui/LoginUI.tsx index e9ea355..0b751ea 100644 --- a/src/ui/LoginUI.tsx +++ b/src/ui/LoginUI.tsx @@ -26,6 +26,7 @@ import { detectProjectType, formatProjectType } from '@/lib/services/project-det import { Header } from '@/ui/components/Header'; import { ProjectSelector } from '@/ui/components/ProjectSelector'; import { StatusMessage } from '@/ui/components/StatusMessage'; +import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; type LoginPhase = | 'checking_existing' @@ -98,6 +99,7 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { const pkceServiceRef = useRef(null); const credentialsRef = useRef(null); const memberRef = useRef(null); + const reopenLink = authUrl ? formatTerminalHyperlink(authUrl, 'Open authentication URL') : null; const handleProjectSelect = useCallback( async (project: Project, org: Organization) => { @@ -302,14 +304,18 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { ⚠ Could not open browser automatically. - - Open this URL in your browser: - - - {authUrl} - )} + {authUrl ? ( + + If browser was closed, reopen this URL: + {reopenLink} + + Direct URL: + {authUrl} + + + ) : null} diff --git a/src/ui/SetupUI.tsx b/src/ui/SetupUI.tsx index c3fb57a..5b9e31b 100644 --- a/src/ui/SetupUI.tsx +++ b/src/ui/SetupUI.tsx @@ -25,6 +25,7 @@ import { detectProjectType } from '@/lib/services/project-detector'; import { Header } from '@/ui/components/Header'; import { ProjectSelector } from '@/ui/components/ProjectSelector'; import { StatusMessage } from '@/ui/components/StatusMessage'; +import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; type SetupPhase = | 'checking_auth' @@ -84,6 +85,7 @@ export const SetupUI: React.FC = ({ onComplete, onError, projectPa const [workspacePath] = useState(() => projectPath ?? process.cwd()); const pkceServiceRef = useRef(null); const memberRef = useRef(null); + const reopenLink = authUrl ? formatTerminalHyperlink(authUrl, 'Open authentication URL') : null; const handleProjectSelect = useCallback( async (project: Project, org: Organization) => { @@ -276,14 +278,18 @@ export const SetupUI: React.FC = ({ onComplete, onError, projectPa ⚠ Could not open browser automatically. - - Open this URL in your browser: - - - {authUrl} - )} + {authUrl ? ( + + If browser was closed, reopen this URL: + {reopenLink} + + Direct URL: + {authUrl} + + + ) : null} diff --git a/src/ui/chat/components/ChatInput.tsx b/src/ui/chat/components/ChatInput.tsx index 0ff488b..7a7b47d 100644 --- a/src/ui/chat/components/ChatInput.tsx +++ b/src/ui/chat/components/ChatInput.tsx @@ -129,48 +129,56 @@ export const ChatInput: React.FC = ({ [filteredCommands, selectedCommandIndex, onHistoryNavigate], ); - useInput((input, key) => { - if (disabled) return; - - // Handle Ctrl+C: clear input or exit on double press - if (isCtrlCInput(input, key)) { - const now = Date.now(); - if (now - lastCtrlCTime.current < DOUBLE_CTRL_C_THRESHOLD) { - // Double Ctrl+C - exit - onExit?.(); - return; - } - lastCtrlCTime.current = now; + const clearInput = useCallback((resetInputComponent: boolean) => { + setValue(''); + setSelectedCommandIndex(0); + if (resetInputComponent) { + setInputKey((prev) => prev + 1); + } + }, []); - // Single Ctrl+C - clear input - if (value) { - setValue(''); - setSelectedCommandIndex(0); - setInputKey((prev) => prev + 1); + useInput( + (input, key) => { + if (disabled) return; + + // Handle Ctrl+C: clear input or exit on double press + if (isCtrlCInput(input, key)) { + const now = Date.now(); + const isDoubleCtrlC = now - lastCtrlCTime.current < DOUBLE_CTRL_C_THRESHOLD; + lastCtrlCTime.current = now; + + if (isDoubleCtrlC) { + // Double Ctrl+C - exit + onExit?.(); + process.exit(130); + return; + } + + // Single Ctrl+C - clear input + if (value) { + clearInput(true); + } + return; } - return; - } - // Handle escape: clear input (always, regardless of menu state) - if (key.escape) { - if (value) { - setValue(''); - setSelectedCommandIndex(0); - setInputKey((prev) => prev + 1); + // Handle escape: clear input (always, regardless of menu state) + if (key.escape) { + if (value) { + clearInput(true); + } + return; } - return; - } - // Handle menu navigation when menu is visible - if (showMenu && filteredCommands.length > 0) { - if (handleMenuNavigation(key)) return; - } + // Handle menu navigation when menu is visible + if (showMenu && filteredCommands.length > 0 && handleMenuNavigation(key)) return; - // Handle history navigation (only when not in slash mode) - if (!showMenu) { - handleHistoryNavigation(key); - } - }); + // Handle history navigation (only when not in slash mode) + if (!showMenu) { + handleHistoryNavigation(key); + } + }, + { isActive: true }, + ); const handleChange = useCallback( (newValue: string) => { @@ -193,17 +201,15 @@ export const ChatInput: React.FC = ({ const selectedCmd = filteredCommands[selectedCommandIndex]; if (selectedCmd) { onSubmit(`/${selectedCmd.command}`); - setValue(''); - setSelectedCommandIndex(0); + clearInput(false); return; } } onSubmit(submittedValue.trim()); - setValue(''); - setSelectedCommandIndex(0); + clearInput(false); }, - [disabled, onSubmit, showMenu, filteredCommands, selectedCommandIndex], + [clearInput, disabled, onSubmit, showMenu, filteredCommands, selectedCommandIndex], ); return ( diff --git a/src/ui/components/AppleLoginUI.tsx b/src/ui/components/AppleLoginUI.tsx index 179a3b5..57790d1 100644 --- a/src/ui/components/AppleLoginUI.tsx +++ b/src/ui/components/AppleLoginUI.tsx @@ -10,19 +10,22 @@ */ import { Auth } from '@expo/apple-utils'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text, useStdin } from 'ink'; import SelectInput from 'ink-select-input'; import Spinner from 'ink-spinner'; import TextInput from 'ink-text-input'; import type React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { + APPLE_KEYS_TOO_MANY_ERROR, createPushKeyAsync, downloadPushKeyAsync, + isAppleKeysTooManyErrorMessage, listPushKeysAsync, loginWithUserCredentialsAsync, type PushKey, type PushKeyStoreInfo, + revokePushKeysAsync, type UserAuthContext, } from '@/lib/ios'; import { useCancelInput } from '@/ui/hooks'; @@ -36,6 +39,8 @@ type AppleLoginPhase = | 'loading_keys' | 'key_selection' | 'creating_key' + | 'key_limit_reached' + | 'revoking_key' | 'downloading_key' | 'success' | 'error'; @@ -58,9 +63,32 @@ export const AppleLoginUI: React.FC = ({ onSuccess, onCancel, const [authContext, setAuthContext] = useState(null); const [existingKeys, setExistingKeys] = useState([]); + const [keyToRevokeId, setKeyToRevokeId] = useState(null); + const { isRawModeSupported, setRawMode } = useStdin(); useCancelInput(onCancel); + useEffect(() => { + if (!isRawModeSupported) { + return; + } + + const requiresKeyboardInput = + phase === 'prompt_login' || + phase === 'apple_id_input' || + phase === 'password_input' || + phase === 'key_selection' || + phase === 'key_limit_reached' || + phase === 'error'; + if (!requiresKeyboardInput) { + return; + } + + // expo/apple-utils auth flow can leave stdin in cooked mode. + // Re-enable raw mode so Ink selectors receive arrow keys properly. + setRawMode(true); + }, [isRawModeSupported, phase, setRawMode]); + const handleStartLogin = useCallback(() => { setPhase('apple_id_input'); }, []); @@ -149,23 +177,6 @@ export const AppleLoginUI: React.FC = ({ onSuccess, onCancel, } }, [appleId, password, onCancel]); - const handleCreateNewKey = useCallback(async () => { - if (!authContext) return; - - setPhase('creating_key'); - setStatusMessage('Creating new APNS key...'); - - try { - const pushKey = await createPushKeyAsync(authContext); - setPhase('success'); - onSuccess({ authContext, pushKey }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to create key'; - setError(message); - setPhase('error'); - } - }, [authContext, onSuccess]); - const handleDownloadKey = useCallback( async (keyId: string) => { if (!authContext) return; @@ -186,13 +197,98 @@ export const AppleLoginUI: React.FC = ({ onSuccess, onCancel, [authContext, onSuccess], ); + const handleKeyLimitReached = useCallback( + async (message: string) => { + if (!authContext) { + setError(message); + setPhase('error'); + return; + } + + try { + setStatusMessage('Loading existing APNS keys...'); + const keys = await listPushKeysAsync(authContext); + setExistingKeys(keys); + setError(message); + setPhase('key_limit_reached'); + } catch (listErr) { + const listMessage = + listErr instanceof Error ? listErr.message : 'Failed to load existing APNS keys'; + setError(`${message}\n\nAdditionally failed to load keys: ${listMessage}`); + setPhase('error'); + } + }, + [authContext], + ); + + const handleRevokeKeyAndRetryCreate = useCallback( + async (keyId: string) => { + if (!authContext) { + setError('Authentication context missing. Please login again.'); + setPhase('error'); + return; + } + + setKeyToRevokeId(keyId); + setStatusMessage(`Revoking APNS key ${keyId}...`); + setPhase('revoking_key'); + + try { + await revokePushKeysAsync(authContext, [keyId]); + const refreshedKeys = await listPushKeysAsync(authContext); + setExistingKeys(refreshedKeys); + setStatusMessage('Creating new APNS key...'); + + const pushKey = await createPushKeyAsync(authContext); + setPhase('success'); + onSuccess({ authContext, pushKey }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to revoke APNS key'; + if (isAppleKeysTooManyErrorMessage(message)) { + await handleKeyLimitReached(message); + return; + } + setError(message); + setPhase('error'); + } + }, + [authContext, handleKeyLimitReached, onSuccess], + ); + + const handleCreateNewKey = useCallback(async () => { + if (!authContext) return; + + setPhase('creating_key'); + setStatusMessage('Creating new APNS key...'); + + try { + const pushKey = await createPushKeyAsync(authContext); + setPhase('success'); + onSuccess({ authContext, pushKey }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create key'; + if (isAppleKeysTooManyErrorMessage(message)) { + await handleKeyLimitReached(message); + return; + } + setError(message); + setPhase('error'); + } + }, [authContext, handleKeyLimitReached, onSuccess]); + const handleRetry = useCallback(() => { setError(null); setPassword(''); + setKeyToRevokeId(null); setPhase('apple_id_input'); }, []); if (phase === 'prompt_login') { + const promptItems: Array<{ label: string; value: 'login' | 'manual' }> = [ + { label: 'Log in with Apple Account', value: 'login' }, + { label: 'Manual setup (create key in browser)', value: 'manual' }, + ]; + return ( = ({ onSuccess, onCancel, • Your password is only used for authentication and stored locally in Keychain - - - {'[L]'} - Log in with Apple Account - - - {'[M]'} - Manual setup (create key in browser) - - - {'[Esc]'} - Cancel - + + { + if (item.value === 'login') { + handleStartLogin(); + } else { + handleManualSetup(); + } + }} + /> + + + ↑↓ to navigate · Enter to select · Esc/Ctrl+C to cancel - ); } @@ -382,6 +478,112 @@ export const AppleLoginUI: React.FC = ({ onSuccess, onCancel, ); } + if (phase === 'key_limit_reached' && authContext) { + const revocableKeys = existingKeys.filter((key) => key.canRevoke); + const nonRevocableKeys = existingKeys.filter((key) => !key.canRevoke); + const keyLimitItems: Array<{ label: string; value: string }> = [ + ...revocableKeys.map((key) => ({ + label: `Delete key: ${key.name} (${key.id})`, + value: `revoke:${key.id}`, + })), + { label: 'Back to key options', value: 'back' }, + { label: 'Manual setup (create key in browser)', value: 'manual' }, + ]; + + return ( + + + + APNS Key Limit Reached + + + + ✗ {error || APPLE_KEYS_TOO_MANY_ERROR.trim()} + + + Apple account key count: {existingKeys.length} + {existingKeys.map((key) => ( + + • {key.name} ({key.id}){key.canRevoke ? ' [deletable]' : ' [not deletable]'} + + ))} + + {revocableKeys.length === 0 && ( + + No revocable keys available. Use manual setup or go back. + + )} + + Select an option: + + { + if (item.value === 'manual') { + handleManualSetup(); + return; + } + if (item.value === 'back') { + setError(null); + setKeyToRevokeId(null); + setPhase('key_selection'); + return; + } + if (item.value.startsWith('revoke:')) { + const keyId = item.value.slice('revoke:'.length); + void handleRevokeKeyAndRetryCreate(keyId); + } + }} + /> + {nonRevocableKeys.length > 0 && ( + + + Note: some keys may require manual cleanup in Apple Developer portal. + + + )} + + ↑↓ to navigate · Enter to select · Esc/Ctrl+C to cancel + + + ); + } + + if (phase === 'revoking_key') { + return ( + + + Apple Developer Account + + + + + + {statusMessage} + + {keyToRevokeId && ( + + Revoking key: {keyToRevokeId} + + )} + + ); + } + if (phase === 'creating_key' || phase === 'downloading_key') { return ( = ({ onSuccess, onCancel, } if (phase === 'error') { + const errorItems: Array<{ label: string; value: 'retry' | 'manual' }> = [ + { label: 'Retry login', value: 'retry' }, + { label: 'Manual setup instead', value: 'manual' }, + ]; + return ( = ({ onSuccess, onCancel, ✗ {error} - - - {'[R]'} - Retry login - - - {'[M]'} - Manual setup instead - - - {'[Esc]'} - Cancel - + + { + if (item.value === 'retry') { + handleRetry(); + } else { + handleManualSetup(); + } + }} + /> + + + ↑↓ to navigate · Enter to select · Esc/Ctrl+C to cancel - ); } @@ -548,7 +755,7 @@ const KeySelectionPhase: React.FC<{ - Esc to cancel + ↑↓ to navigate · Enter to select · Esc/Ctrl+C to cancel ); @@ -588,37 +795,3 @@ async function buildAuthContextFromSession( fastlaneSession, }; } - -/** - * Helper component for prompt_login phase input handling. - */ -const AppleLoginPromptInput: React.FC<{ - onLogin: () => void; - onManual: () => void; -}> = ({ onLogin, onManual }) => { - useInput((input) => { - if (input.toLowerCase() === 'l') { - onLogin(); - } else if (input.toLowerCase() === 'm') { - onManual(); - } - }); - return null; -}; - -/** - * Helper component for error phase input handling. - */ -const AppleLoginErrorInput: React.FC<{ - onRetry: () => void; - onManual: () => void; -}> = ({ onRetry, onManual }) => { - useInput((input) => { - if (input.toLowerCase() === 'r') { - onRetry(); - } else if (input.toLowerCase() === 'm') { - onManual(); - } - }); - return null; -}; diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx index fe690c7..0761749 100644 --- a/src/ui/components/FirebaseConfigFilesSetup.tsx +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -15,6 +15,7 @@ import { } from '@/lib/services/firebase'; import { OAUTH_CALLBACK_CONFIG } from '@/lib/utils/oauth'; import { useCancelInput } from '@/ui/hooks'; +import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; export interface NoAppsContext { @@ -101,33 +102,54 @@ function StatusPhase({ ); } -function AuthenticatingPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { +function AuthenticatingPhase({ + onCancel, + authUrl, +}: { + onCancel: () => void; + authUrl?: string | null; +}): React.ReactElement { useCancelInput(onCancel); + const reopenLink = authUrl ? formatTerminalHyperlink(authUrl, 'Open authentication URL') : null; return ( - - - Firebase Authentication - - - - - - Opening browser for Google authentication... - - - Complete authentication in your browser. - - - Press Esc/Ctrl+C to cancel + + + + Firebase Authentication + + + + + + Opening browser for Google authentication... + + + Complete authentication in your browser. + + {reopenLink ? ( + + If browser was closed, reopen this URL: + {reopenLink} + + ) : null} + + Press Esc/Ctrl+C to cancel + + {authUrl ? ( + + Direct URL: + {authUrl} + + ) : null} ); } diff --git a/src/ui/components/FirebaseServiceAccountSetup.tsx b/src/ui/components/FirebaseServiceAccountSetup.tsx index fafae39..89c3fe7 100644 --- a/src/ui/components/FirebaseServiceAccountSetup.tsx +++ b/src/ui/components/FirebaseServiceAccountSetup.tsx @@ -2,7 +2,8 @@ import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; import TextInput from 'ink-text-input'; import type React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { openBrowser } from '@/lib/auth/browser'; import { type FirebaseDetectionResult, parseServiceAccountJson, @@ -185,9 +186,20 @@ function PasteServiceAccountPhase({ const [errorMsg, setErrorMsg] = useState(null); const [loading, setLoading] = useState(false); const [loadingText, setLoadingText] = useState(''); + const [browserOpened, setBrowserOpened] = useState(false); + const serviceAccountConsoleUrl = `https://console.firebase.google.com/project/${projectId}/settings/serviceaccounts/adminsdk`; useCancelInput(onCancel); + useEffect(() => { + if (browserOpened) { + return; + } + + void openBrowser(serviceAccountConsoleUrl); + setBrowserOpened(true); + }, [browserOpened, serviceAccountConsoleUrl]); + const processJson = useCallback( (content: string, source: string) => { const parsed = parseServiceAccountJson(content); @@ -258,9 +270,7 @@ function PasteServiceAccountPhase({ Project: {projectId} 1. Open Firebase Console and generate a new private key JSON: - - https://console.firebase.google.com/project/{projectId}/settings/serviceaccounts/adminsdk - + {serviceAccountConsoleUrl} 2. Copy JSON to clipboard and press Enter, or drag the JSON file here. diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index 0928954..95027e6 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -7,24 +7,36 @@ * @module ui/components/InstallPreparationUI */ -import { Box, Text, useInput } from 'ink'; +import * as path from 'node:path'; +import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { ApnsStatus, IosStatus, PreparationContext } from '@/commands/skill/preparation'; import { gatherPreparationContext, saveSetupStatus } from '@/commands/skill/preparation'; import { openBrowser } from '@/lib/auth/browser'; import type { ProjectType } from '@/lib/config'; import { + addClixToExtensionTarget, + addNotificationServiceExtension, analyzeIosProject, createExtensionFiles, type ExtensionContext, type ExtensionGeneratorResult, + ensureNotificationServiceSwiftProjectId, generateAppGroupId, getEntitlementsPath, getExtensionBundleId, getExtensionName, getIosProjectDir, + getNotificationServiceExtensionStatus, + hasClixPodInExtensionTarget, + hasExtensionTarget, + hasNotificationServiceExtension, + hasPodfile, + inspectNotificationServiceSwift, + type PbxprojModificationResult, + type PodfileModificationResult, verifyExtensionFiles, } from '@/lib/ios'; import type { PushSetupContext } from '@/lib/push'; @@ -41,7 +53,7 @@ import { type ServiceAccountJson, } from '@/lib/services/firebase'; import { detectProjectType, formatProjectType } from '@/lib/services/project-detector'; -import { isCtrlCInput, useCancelInput } from '@/ui/hooks'; +import { useCancelInput } from '@/ui/hooks'; import { FirebaseConfigAddingFirebaseTask, FirebaseConfigAppSelectorTask, @@ -90,6 +102,7 @@ import { NotificationExtensionCompleteTask, NotificationExtensionDependenciesTask, type NotificationExtensionSetupContext, + type NotificationExtensionVerificationChecks, NotificationExtensionVerificationTask, NotificationExtensionXcodeTask, } from './notification-extension-setup/NotificationExtensionTasks'; @@ -154,9 +167,13 @@ interface StatusLayoutPolicy { const FALLBACK_TERMINAL_ROWS = 24; const COMPACT_ROWS_THRESHOLD = 33; const MINIMAL_ROWS_THRESHOLD = 28; +const TASK_OVERRIDE_ENV_NAME = 'CLIX_DEV_ENABLE_TASK_OVERRIDE'; +const START_TASK_ENV_NAME = 'CLIX_INSTALL_START_TASK'; +const INSTALL_TASK_IDS = Object.keys(INSTALL_TASK_LABELS) as InstallTaskId[]; interface InstallPreparationUIProps { projectPath?: string; + startTaskId?: InstallTaskId; onComplete: (context: PreparationContext) => void; onCancel: () => void; } @@ -178,6 +195,8 @@ interface IosGuidedContextCache { appGroupId: string; appName: string; iosDir: string; + xcodeprojPath: string; + projectId: string; entitlementsPath: string; } @@ -190,6 +209,7 @@ interface FirebaseConfigState { detection: FirebaseDetectionResult | null; projectType: ProjectType | null; service: FirebaseService | null; + authUrl: string | null; projects: FirebaseProject[]; selectedProject: FirebaseProject | null; androidApps: AndroidApp[]; @@ -222,16 +242,33 @@ interface ApnsState { interface NotificationExtensionState { context: NotificationExtensionSetupContext | null; extensionResult: ExtensionGeneratorResult | null; + xcodeResult: PbxprojModificationResult | null; + podfileResult: PodfileModificationResult | null; + verificationChecks: NotificationExtensionVerificationChecks | null; + warnings: string[]; error: string | null; } +interface StartTaskOverrideValidationInput { + context: PreparationContext; + effectiveStartTaskId: InstallTaskId | null; + invalidEnvStartTask: string | null; + taskOverrideEnabled: boolean; +} + +interface StartTaskOverrideValidationResult { + taskId: InstallTaskId | null; + note: string | null; +} + async function buildNotificationExtensionContext( projectPath: string, iosBundleId: string | undefined, iosAppGroupId: string | undefined, + firebaseProjectId: string | undefined, cachedContext: IosGuidedContextCache | null, ): Promise { - if (cachedContext) { + if (cachedContext?.projectId) { return cachedContext; } @@ -250,12 +287,18 @@ async function buildNotificationExtensionContext( const entitlementsPath = analysis.project.entitlementsFiles[0] || getEntitlementsPath(iosDir, targetName); const appGroupId = iosAppGroupId || generateAppGroupId(bundleId); + const projectId = firebaseProjectId || ''; + if (!projectId) { + throw new Error('Firebase project ID is required for Notification Service Extension setup.'); + } return { bundleId, appGroupId, appName: analysis.project.appName, iosDir, + xcodeprojPath: analysis.project.projectPath, + projectId, entitlementsPath, }; } @@ -508,29 +551,6 @@ function TaskHeader({ title, subtitle }: { title: string; subtitle: string }): R ); } -function GuidedStepContainer({ - onNext, - onCancel, - children, -}: { - onNext: () => void; - onCancel: () => void; - children: React.ReactNode; -}): React.ReactElement { - useInput((input, key) => { - if (key.return || input === ' ') { - onNext(); - return; - } - - if (key.escape || isCtrlCInput(input, key)) { - onCancel(); - } - }); - - return {children}; -} - function getInitialLeafTaskId(taskId: InstallTaskId): InstallLeafTaskId { switch (taskId) { case 'firebase_config_files': @@ -553,6 +573,7 @@ function createInitialFirebaseConfigState(): FirebaseConfigState { detection: null, projectType: null, service: null, + authUrl: null, projects: [], selectedProject: null, androidApps: [], @@ -595,10 +616,74 @@ function createInitialNotificationExtensionState(): NotificationExtensionState { return { context: null, extensionResult: null, + xcodeResult: null, + podfileResult: null, + verificationChecks: null, + warnings: [], error: null, }; } +function collectNseVerificationChecks( + nseContext: NotificationExtensionSetupContext, +): NotificationExtensionVerificationChecks { + const extensionName = getExtensionName(nseContext.appName); + const fileVerification = verifyExtensionFiles(nseContext.iosDir, nseContext.appName); + const xcodeTargetConfigured = hasNotificationServiceExtension( + nseContext.xcodeprojPath, + extensionName, + ); + const xcodeStatus = getNotificationServiceExtensionStatus( + nseContext.xcodeprojPath, + extensionName, + ); + const buildSettingsConfigured = + xcodeStatus.buildSettings.enableUserScriptSandboxingNo && + xcodeStatus.buildSettings.infoPlistConfigured && + xcodeStatus.buildSettings.codeSignEntitlementsConfigured; + + const podfileExists = hasPodfile(nseContext.iosDir); + const podDependencyConfigured = + !podfileExists || + (hasExtensionTarget(nseContext.iosDir, extensionName) && + hasClixPodInExtensionTarget(nseContext.iosDir, extensionName)); + + const swiftStatus = inspectNotificationServiceSwift(nseContext.iosDir, nseContext.appName); + const notificationServiceConfigured = + swiftStatus.exists && + swiftStatus.importsClix && + swiftStatus.inheritsClixNse && + swiftStatus.hasRegisterCall && + swiftStatus.hasSuperDidReceive && + swiftStatus.registeredProjectId === nseContext.projectId; + + const missingReasons: string[] = []; + if (!fileVerification.complete) { + missingReasons.push(`Missing NSE files: ${fileVerification.missingFiles.join(', ')}`); + } + if (!xcodeTargetConfigured) { + missingReasons.push('Xcode Notification Service Extension target is missing'); + } + if (!buildSettingsConfigured) { + missingReasons.push('NSE build settings are incomplete'); + } + if (!podDependencyConfigured) { + missingReasons.push('Clix dependency is not configured in Podfile extension target'); + } + if (!notificationServiceConfigured) { + missingReasons.push('NotificationService.swift is not fully configured for Clix'); + } + + return { + filesComplete: fileVerification.complete, + xcodeTargetConfigured, + buildSettingsConfigured, + podDependencyConfigured, + notificationServiceConfigured, + missingReasons, + }; +} + function isFirebaseScopeError(error: string): boolean { return ( error.includes('ACCESS_TOKEN_SCOPE_INSUFFICIENT') || @@ -608,15 +693,75 @@ function isFirebaseScopeError(error: string): boolean { ); } +function validateStartTaskOverride({ + context, + effectiveStartTaskId, + invalidEnvStartTask, + taskOverrideEnabled, +}: StartTaskOverrideValidationInput): StartTaskOverrideValidationResult { + if (!effectiveStartTaskId) { + if (invalidEnvStartTask) { + return { + taskId: null, + note: `Ignoring ${START_TASK_ENV_NAME}="${invalidEnvStartTask}" because it is not a valid install task id.`, + }; + } + return { taskId: null, note: null }; + } + + if (!taskOverrideEnabled) { + return { + taskId: null, + note: `Task override was requested but disabled. Set ${TASK_OVERRIDE_ENV_NAME}=1 to enable it.`, + }; + } + + const applicableTasks = getApplicableInstallTasks(context); + if (!applicableTasks.includes(effectiveStartTaskId)) { + return { + taskId: null, + note: `Task override "${effectiveStartTaskId}" is not applicable for this project (${formatProjectType(context.projectType)}).`, + }; + } + + if (effectiveStartTaskId === 'notification_service_extension' && !context.firebase.projectId) { + return { + taskId: null, + note: 'Task override requires a detected Firebase project ID. Configure Firebase Configuration Files first.', + }; + } + + if (effectiveStartTaskId === 'notification_service_extension' && !context.ios.bundleId) { + return { + taskId: null, + note: 'Task override requires a detected iOS bundle ID.', + }; + } + + return { taskId: effectiveStartTaskId, note: null }; +} + /** * Install preparation UI component. */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Central orchestration for install preparation leaf tasks. export function InstallPreparationUI({ projectPath = process.cwd(), + startTaskId, onComplete, onCancel, }: InstallPreparationUIProps): React.ReactElement { + const taskOverrideEnabled = process.env[TASK_OVERRIDE_ENV_NAME] === '1'; + const envStartTaskRaw = process.env[START_TASK_ENV_NAME]; + const envStartTaskId = + envStartTaskRaw && INSTALL_TASK_IDS.includes(envStartTaskRaw as InstallTaskId) + ? (envStartTaskRaw as InstallTaskId) + : null; + const invalidEnvStartTask = envStartTaskRaw && !envStartTaskId ? envStartTaskRaw : null; + const effectiveStartTaskId = startTaskId ?? envStartTaskId; + const startTaskOverrideHandledRef = useRef(false); + const nseCompletionHandledRef = useRef(false); + const [phase, setPhase] = useState('checking'); const [context, setContext] = useState(null); const [activeTaskId, setActiveTaskId] = useState(null); @@ -635,6 +780,20 @@ export function InstallPreparationUI({ const [firebaseConfigDownloader] = useState(() => new FirebaseDownloader()); const [serviceAccountDownloader] = useState(() => new FirebaseDownloader()); + const cancelFirebaseAuthentication = useCallback( + (reason = 'Install preparation cancelled') => { + firebaseConfigDownloader.cancelAuthentication(reason); + serviceAccountDownloader.cancelAuthentication(reason); + }, + [firebaseConfigDownloader, serviceAccountDownloader], + ); + + useEffect(() => { + return () => { + cancelFirebaseAuthentication('Install preparation closed'); + }; + }, [cancelFirebaseAuthentication]); + const resetTaskRuntimeState = useCallback((preparationContext?: PreparationContext) => { setActiveLeafTaskId(null); setFirebaseConfigState(createInitialFirebaseConfigState()); @@ -643,6 +802,18 @@ export function InstallPreparationUI({ setApnsState(preparationContext ? createInitialApnsState(preparationContext) : null); }, []); + const beginTask = useCallback( + (taskId: InstallTaskId, preparationContext: PreparationContext) => { + setNote(null); + nseCompletionHandledRef.current = false; + setActiveTaskId(taskId); + resetTaskRuntimeState(preparationContext); + setActiveLeafTaskId(getInitialLeafTaskId(taskId)); + setPhase('task'); + }, + [resetTaskRuntimeState], + ); + const loadPreparationContext = useCallback(async (): Promise => { const nextContext = await gatherPreparationContext(projectPath); if (!nextContext) { @@ -670,7 +841,27 @@ export function InstallPreparationUI({ } setContext(nextContext); - setPhase('status'); + if (startTaskOverrideHandledRef.current) { + setPhase('status'); + return; + } + startTaskOverrideHandledRef.current = true; + + const overrideDecision = validateStartTaskOverride({ + context: nextContext, + effectiveStartTaskId, + invalidEnvStartTask, + taskOverrideEnabled, + }); + if (!overrideDecision.taskId) { + if (overrideDecision.note) { + setNote(overrideDecision.note); + } + setPhase('status'); + return; + } + + beginTask(overrideDecision.taskId, nextContext); }; void check(); @@ -678,7 +869,7 @@ export function InstallPreparationUI({ return () => { mounted = false; }; - }, [projectPath]); + }, [beginTask, effectiveStartTaskId, invalidEnvStartTask, projectPath, taskOverrideEnabled]); const applyTaskCompletion = useCallback( async (patch?: TaskCompletionPatch) => { @@ -711,12 +902,13 @@ export function InstallPreparationUI({ const handleTaskBackToStatus = useCallback( (nextNote?: string) => { + cancelFirebaseAuthentication('Setup step cancelled'); setActiveTaskId(null); resetTaskRuntimeState(context ?? undefined); setNote(nextNote ?? 'Setup step was not completed. Continue required setup to proceed.'); setPhase('status'); }, - [context, resetTaskRuntimeState], + [cancelFirebaseAuthentication, context, resetTaskRuntimeState], ); const handleContinue = useCallback(() => { @@ -736,19 +928,13 @@ export function InstallPreparationUI({ } setNote(null); - setActiveTaskId(nextTaskId); - setActiveLeafTaskId(getInitialLeafTaskId(nextTaskId)); - setFirebaseConfigState(createInitialFirebaseConfigState()); - setFirebaseServiceAccountState(createInitialFirebaseServiceAccountState()); - setNotificationExtensionState(createInitialNotificationExtensionState()); - setApnsState(createInitialApnsState(context)); - setPhase('task'); - }, [context, onComplete]); + beginTask(nextTaskId, context); + }, [beginTask, context, onComplete]); const handleCancelPreparation = useCallback(() => { - setPhase('cancelled'); + cancelFirebaseAuthentication('Install preparation cancelled'); onCancel(); - }, [onCancel]); + }, [cancelFirebaseAuthentication, onCancel]); const getFirebaseConfigNeeds = useCallback((detection: FirebaseDetectionResult | null) => { const platform = detection?.platform ?? 'unknown'; @@ -1129,13 +1315,21 @@ export function InstallPreparationUI({ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Step branching mirrors Firebase setup decision tree. const authenticate = async () => { try { + setFirebaseConfigState((prev) => ({ ...prev, authUrl: null })); + if (!isOAuthConfigured()) { throw new Error('Google OAuth is not configured. Set Firebase OAuth credentials first.'); } const isAuthenticated = await firebaseConfigDownloader.isAuthenticated(); if (!isAuthenticated) { - const authResult = await firebaseConfigDownloader.authenticate(openBrowser); + const authResult = await firebaseConfigDownloader.authenticate((url) => { + if (cancelled) { + return; + } + setFirebaseConfigState((prev) => ({ ...prev, authUrl: url })); + void openBrowser(url); + }); if (!authResult.success) { throw new Error(authResult.error || 'Firebase authentication failed.'); } @@ -1175,6 +1369,9 @@ export function InstallPreparationUI({ return () => { cancelled = true; + firebaseConfigDownloader.cancelAuthentication( + 'Firebase configuration authentication cancelled', + ); }; }, [ activeLeafTaskId, @@ -1589,6 +1786,7 @@ export function InstallPreparationUI({ projectPath, context?.ios.bundleId, context?.ios.appGroupId, + context?.firebase.projectId, guidedContextCache, ); @@ -1618,6 +1816,7 @@ export function InstallPreparationUI({ activeTaskId, context?.ios.appGroupId, context?.ios.bundleId, + context?.firebase.projectId, guidedContextCache, handleTaskBackToStatus, phase, @@ -1646,6 +1845,7 @@ export function InstallPreparationUI({ appName: nseContext.appName, bundleId: nseContext.bundleId, iosDir: nseContext.iosDir, + projectId: nseContext.projectId, }; const result = await createExtensionFiles(extensionContext); @@ -1653,7 +1853,11 @@ export function InstallPreparationUI({ return; } - setNotificationExtensionState((prev) => ({ ...prev, extensionResult: result })); + setNotificationExtensionState((prev) => ({ + ...prev, + extensionResult: result, + warnings: result.warnings, + })); if (!result.success) { setNotificationExtensionState((prev) => ({ ...prev, @@ -1679,6 +1883,231 @@ export function InstallPreparationUI({ phase, ]); + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'notification_service_extension' || + activeLeafTaskId !== 'nse_xcode_target' + ) { + return; + } + + const nseContext = notificationExtensionState.context; + if (!nseContext || notificationExtensionState.xcodeResult) { + return; + } + + let cancelled = false; + + const applyXcodeSetup = async () => { + try { + const extensionName = getExtensionName(nseContext.appName); + const extensionBundleId = getExtensionBundleId(nseContext.bundleId, nseContext.appName); + const extensionDir = path.join(nseContext.iosDir, extensionName); + + const swiftPatch = ensureNotificationServiceSwiftProjectId( + nseContext.iosDir, + nseContext.appName, + nseContext.projectId, + ); + + const xcodeResult = await addNotificationServiceExtension({ + projectPath: nseContext.xcodeprojPath, + extensionName, + extensionBundleId, + extensionDir, + appGroupId: nseContext.appGroupId, + teamId: context?.ios.teamId, + }); + + if (cancelled) { + return; + } + + setNotificationExtensionState((prev) => ({ + ...prev, + xcodeResult, + warnings: [...prev.warnings, ...swiftPatch.warnings, ...xcodeResult.warnings], + })); + + if (!xcodeResult.success) { + setNotificationExtensionState((prev) => ({ + ...prev, + error: xcodeResult.error || 'Failed to apply NSE Xcode target setup', + })); + setActiveLeafTaskId('nse_complete'); + return; + } + + setActiveLeafTaskId('nse_build_settings'); + } catch (err) { + if (cancelled) { + return; + } + setNotificationExtensionState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Failed to configure NSE target', + })); + setActiveLeafTaskId('nse_complete'); + } + }; + + void applyXcodeSetup(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + context?.ios.teamId, + notificationExtensionState.context, + notificationExtensionState.xcodeResult, + phase, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'notification_service_extension' || + activeLeafTaskId !== 'nse_build_settings' + ) { + return; + } + + setActiveLeafTaskId('nse_dependencies'); + }, [activeLeafTaskId, activeTaskId, phase]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'notification_service_extension' || + activeLeafTaskId !== 'nse_dependencies' + ) { + return; + } + + const nseContext = notificationExtensionState.context; + if (!nseContext || notificationExtensionState.podfileResult) { + return; + } + + let cancelled = false; + + const applyDependencySetup = async () => { + const extensionName = getExtensionName(nseContext.appName); + const podfileResult = await addClixToExtensionTarget({ + iosDir: nseContext.iosDir, + extensionName, + }); + + if (cancelled) { + return; + } + + setNotificationExtensionState((prev) => ({ + ...prev, + podfileResult, + warnings: [ + ...prev.warnings, + ...(podfileResult.podfileExists + ? [] + : [ + 'Podfile not found. Configure Clix dependency manually (SPM or custom build setup).', + ]), + ], + })); + setActiveLeafTaskId('nse_verification'); + }; + + void applyDependencySetup(); + + return () => { + cancelled = true; + }; + }, [ + activeLeafTaskId, + activeTaskId, + notificationExtensionState.context, + notificationExtensionState.podfileResult, + phase, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'notification_service_extension' || + activeLeafTaskId !== 'nse_verification' + ) { + return; + } + + const nseContext = notificationExtensionState.context; + if (!nseContext) { + return; + } + + const checks = collectNseVerificationChecks(nseContext); + setNotificationExtensionState((prev) => ({ ...prev, verificationChecks: checks })); + setActiveLeafTaskId('nse_complete'); + }, [activeLeafTaskId, activeTaskId, notificationExtensionState.context, phase]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'notification_service_extension' || + activeLeafTaskId !== 'nse_complete' + ) { + return; + } + + if (nseCompletionHandledRef.current) { + return; + } + nseCompletionHandledRef.current = true; + + if (notificationExtensionState.error) { + handleTaskBackToStatus(notificationExtensionState.error); + return; + } + + const nseContext = notificationExtensionState.context; + if (!nseContext) { + handleTaskBackToStatus('Notification extension context is missing.'); + return; + } + if (!context) { + handleTaskBackToStatus('Install preparation context is missing.'); + return; + } + + const checks = + notificationExtensionState.verificationChecks ?? collectNseVerificationChecks(nseContext); + if (checks.missingReasons.length > 0) { + handleTaskBackToStatus( + `Notification Service Extension setup is incomplete: ${checks.missingReasons[0]}`, + ); + return; + } + + void applyTaskCompletion({ + ios: { + needed: context.ios.needed, + nseConfigured: true, + }, + }); + }, [ + activeLeafTaskId, + activeTaskId, + applyTaskCompletion, + context, + handleTaskBackToStatus, + notificationExtensionState.context, + notificationExtensionState.error, + notificationExtensionState.verificationChecks, + phase, + ]); + if (phase === 'checking') { return ; } @@ -1733,7 +2162,10 @@ export function InstallPreparationUI({ )} {activeLeafTaskId === 'firebase_config_authenticating' && ( - handleTaskBackToStatus()} /> + handleTaskBackToStatus()} + /> )} {activeLeafTaskId === 'firebase_config_select_project' && ( @@ -2062,6 +2494,8 @@ export function InstallPreparationUI({ appGroupId: result.agentContext.appGroupId, appName: result.agentContext.appName, iosDir: result.agentContext.iosDir, + xcodeprojPath: result.agentContext.projectPath, + projectId: context.firebase.projectId || '', entitlementsPath: result.agentContext.entitlementsPath, }); } @@ -2118,80 +2552,43 @@ export function InstallPreparationUI({ )} {activeLeafTaskId === 'nse_xcode_target' && nseContext && ( - setActiveLeafTaskId('nse_build_settings')} - onCancel={() => handleTaskBackToStatus()} - > - - + )} {activeLeafTaskId === 'nse_build_settings' && nseContext && ( - setActiveLeafTaskId('nse_dependencies')} - onCancel={() => handleTaskBackToStatus()} - > - - + )} {activeLeafTaskId === 'nse_dependencies' && ( - setActiveLeafTaskId('nse_verification')} - onCancel={() => handleTaskBackToStatus()} - > - - + )} {activeLeafTaskId === 'nse_verification' && nseContext && ( - { - const verification = verifyExtensionFiles(nseContext.iosDir, nseContext.appName); - if (!verification.complete) { - handleTaskBackToStatus('Notification Service Extension files are still missing.'); - return; - } - setActiveLeafTaskId('nse_complete'); - }} - onCancel={() => handleTaskBackToStatus()} - > - - + )} {activeLeafTaskId === 'nse_complete' && ( - { - if (notificationExtensionState.error) { - handleTaskBackToStatus(notificationExtensionState.error); - return; - } - void applyTaskCompletion({ - ios: { - needed: context.ios.needed, - nseConfigured: true, - }, - }); - }} - onCancel={() => handleTaskBackToStatus()} - > - - + )} ); diff --git a/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx b/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx index 47b9cbb..dcc8da3 100644 --- a/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx +++ b/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx @@ -3,9 +3,10 @@ */ import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; import type React from 'react'; +import type { PbxprojModificationResult, PodfileModificationResult } from '@/lib/ios'; import { type ExtensionGeneratorResult, verifyExtensionFiles } from '@/lib/ios/extension-generator'; -import { generatePodfileSnippet } from '@/lib/ios/extension-templates'; import { StatusMessage } from '../StatusMessage'; export interface NotificationExtensionSetupContext { @@ -13,20 +14,38 @@ export interface NotificationExtensionSetupContext { appGroupId: string; appName: string; iosDir: string; + xcodeprojPath: string; + projectId: string; entitlementsPath: string; pushEnvironment?: 'development' | 'production'; } +export interface NotificationExtensionVerificationChecks { + filesComplete: boolean; + xcodeTargetConfigured: boolean; + buildSettingsConfigured: boolean; + podDependencyConfigured: boolean; + notificationServiceConfigured: boolean; + missingReasons: string[]; +} + export const NotificationExtensionXcodeTask: React.FC<{ extensionName: string; extensionBundleId: string; extensionResult: ExtensionGeneratorResult | null; appGroupId: string; -}> = ({ extensionName, extensionBundleId, extensionResult, appGroupId }) => ( + xcodeResult: PbxprojModificationResult | null; +}> = ({ extensionName, extensionBundleId, extensionResult, appGroupId, xcodeResult }) => ( + + + Xcode Target Configuration + + + {extensionResult && extensionResult.createdFiles.length > 0 && ( - + {extensionResult.createdFiles.map((filePath) => ( • {filePath} @@ -35,37 +54,27 @@ export const NotificationExtensionXcodeTask: React.FC<{ )} - - - Step 1: Create Notification Service Extension Target in Xcode - - - - - 1. Open your project in Xcode - 2. File → New → Target... - 3. Select "Notification Service Extension" - - 4. Product Name: {extensionName} - - - 5. Bundle Identifier: {extensionBundleId} - - 6. Click "Finish" - - - - After creating the target: - • Delete the auto-generated NotificationService.swift - • Copy files from the created extension directory - - • Add App Group capability: {appGroupId} - - - - - Press Enter to continue... - + {!xcodeResult ? ( + + + + + Applying Xcode target and build settings... + + ) : ( + + + {xcodeResult.success ? '✓' : '✗'} Xcode project update + + Target: {extensionName} + Bundle ID: {extensionBundleId} + App Group: {appGroupId} + + {xcodeResult.targetAdded ? '✓ Target added' : '• Existing target reused'} + + {xcodeResult.error ? Error: {xcodeResult.error} : null} + + )} ); @@ -74,137 +83,117 @@ export const NotificationExtensionBuildSettingsTask: React.FC<{ entitlementsPath: string; }> = ({ extensionName, entitlementsPath }) => ( - - - + - Step 2: Configure Build Settings + Build Settings - - - For the main app target: - 1. Select target → Signing & Capabilities - 2. Verify entitlements file is linked: - - {entitlementsPath} - - - - - For {extensionName}: - 1. Select extension target → Build Settings - - 2. Search for ENABLE_USER_SCRIPT_SANDBOXING - - - 3. Set to No (required for Xcode 15+) - - - - - - For React Native + Firebase projects: - - • Build Phases → Move "Embed Foundation Extensions" - above "[RNFB] Core Configuration" - - - - Press Enter to continue... + + ✓ Extension build settings applied + Target: {extensionName} + Entitlements: {entitlementsPath} ); export const NotificationExtensionDependenciesTask: React.FC<{ extensionName: string; -}> = ({ extensionName }) => { - const podfileSnippet = generatePodfileSnippet(extensionName); - - return ( - - - - - - Step 3: Add Clix SDK to Extension Target - - + podfileResult: PodfileModificationResult | null; +}> = ({ extensionName, podfileResult }) => ( + + + + Dependency Configuration + + - - For CocoaPods projects: - Add to your Podfile: - - {podfileSnippet} - - - Then run: cd ios && pod install + {!podfileResult ? ( + + + + Configuring Clix dependency for {extensionName}... - - - For Swift Package Manager: - 1. Select the extension target in Xcode - 2. General → Frameworks, Libraries, and Embedded Content - 3. Click + and add the Clix package - - - - Press Enter to continue... + ) : ( + + {podfileResult.podfileExists ? ( + + + {podfileResult.targetAdded ? '✓' : '•'} Podfile extension target + + + {podfileResult.clixPodAdded ? '✓' : '•'} Clix pod dependency + + + ) : ( + • Podfile not found (SPM/custom setup) + )} - - ); -}; + )} + +); export const NotificationExtensionVerificationTask: React.FC<{ context: NotificationExtensionSetupContext; extensionResult: ExtensionGeneratorResult | null; -}> = ({ context, extensionResult }) => { - const verification = verifyExtensionFiles(context.iosDir, context.appName); + checks?: NotificationExtensionVerificationChecks | null; +}> = ({ context, extensionResult, checks }) => { + const fileVerification = verifyExtensionFiles(context.iosDir, context.appName); + const effectiveChecks: NotificationExtensionVerificationChecks = checks ?? { + filesComplete: fileVerification.complete, + xcodeTargetConfigured: false, + buildSettingsConfigured: false, + podDependencyConfigured: false, + notificationServiceConfigured: false, + missingReasons: fileVerification.complete + ? [] + : [`Missing files: ${fileVerification.missingFiles.join(', ')}`], + }; return ( - - - + - Step 4: Verification + Verification - Extension Files: - {verification.complete ? ( - - ) : ( - - - {verification.missingFiles.map((filePath) => ( - - • {filePath} - - ))} - - )} - - - - Extension Directory: - {extensionResult?.extensionDir} - - - - Important Reminders: - - • Replace YOUR_PROJECT_ID in NotificationService.swift + + {effectiveChecks.filesComplete ? '✓' : '✗'} Extension files + + + {effectiveChecks.xcodeTargetConfigured ? '✓' : '✗'} Xcode NSE target + + + {effectiveChecks.buildSettingsConfigured ? '✓' : '✗'} NSE build settings + + + {effectiveChecks.podDependencyConfigured ? '✓' : '✗'} Clix dependency + + + {effectiveChecks.notificationServiceConfigured ? '✓' : '✗'} NotificationService.swift - • Extension must share the same App Group as main app - • Regenerate provisioning profiles if needed - - Press Enter to finish... - + {effectiveChecks.missingReasons.length > 0 && ( + + + Remaining issues: + + {effectiveChecks.missingReasons.map((reason) => ( + + • {reason} + + ))} + + )} + + {extensionResult?.extensionDir ? ( + + Directory: {extensionResult.extensionDir} + + ) : null} ); }; @@ -212,7 +201,8 @@ export const NotificationExtensionVerificationTask: React.FC<{ export const NotificationExtensionCompleteTask: React.FC<{ error: string | null; extensionResult: ExtensionGeneratorResult | null; -}> = ({ error, extensionResult }) => ( + warnings?: string[]; +}> = ({ error, extensionResult, warnings = [] }) => ( {error ? ( @@ -220,19 +210,28 @@ export const NotificationExtensionCompleteTask: React.FC<{ - ✓ Extension setup guide complete! + ✓ Notification Service Extension setup completed - {extensionResult && extensionResult.createdFiles.length > 0 && ( + {extensionResult?.createdFiles.length ? ( - Created files: {extensionResult.createdFiles.map((filePath) => ( • {filePath} ))} - )} + ) : null} + {warnings.length > 0 ? ( + + Warnings: + {warnings.map((warning) => ( + + • {warning} + + ))} + + ) : null} )} diff --git a/src/ui/components/push-setup/PushSetupTasks.tsx b/src/ui/components/push-setup/PushSetupTasks.tsx index 1a1cb33..e39f237 100644 --- a/src/ui/components/push-setup/PushSetupTasks.tsx +++ b/src/ui/components/push-setup/PushSetupTasks.tsx @@ -13,6 +13,7 @@ import TextInput from 'ink-text-input'; import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { openBrowser } from '@/lib/auth/browser'; +import { PROJECT_CONFIG_DIR } from '@/lib/config/project-config-schema'; import { analyzeIosProject } from '@/lib/ios'; import { APNS_KEY_CREATION_STEPS, @@ -27,6 +28,7 @@ import { FirebaseDownloader, type FirebaseProject, FirebaseService } from '@/lib import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; import { detectProjectType } from '@/lib/services/project-detector'; import { isCtrlCInput, useCancelInput } from '@/ui/hooks'; +import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; import { AppleLoginUI } from '../AppleLoginUI'; import { normalizeInputFilePath } from '../file-input-utils'; @@ -442,7 +444,9 @@ function AppleGuidePhase({ ))} - Press Enter when you have copied the .p8 file to this directory + + Press Enter when you have copied the .p8 file to ./{PROJECT_CONFIG_DIR} + Esc/Ctrl+C to cancel @@ -454,12 +458,27 @@ function AppleGuidePhase({ function findP8Files(): string[] { try { const cwd = process.cwd(); - const files = fs.readdirSync(cwd); + const searchDirs = [cwd, path.join(cwd, PROJECT_CONFIG_DIR)]; + const files = new Set(); + + for (const dir of searchDirs) { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + continue; + } + + const directoryFiles = fs.readdirSync(dir); + for (const fileName of directoryFiles) { + if (!fileName.endsWith('.p8')) { + continue; + } + + const fullPath = path.join(dir, fileName); + const relativePath = path.relative(cwd, fullPath); + files.add(relativePath.startsWith('.') ? relativePath : `./${relativePath}`); + } + } - return files - .filter((fileName) => fileName.endsWith('.p8')) - .map((fileName) => `./${fileName}`) - .sort(); + return [...files].sort(); } catch { return []; } @@ -496,11 +515,6 @@ function P8InputPhase({ useInput((input, key) => { if (key.escape || isCtrlCInput(input, key)) { onCancel(); - return; - } - - if (stage === 'team_id' && input === 't') { - openBrowser(PUSH_SETUP_URLS.appleTeamId); } }); @@ -601,7 +615,8 @@ function P8InputPhase({ <> - Found {foundFiles.length} P8 file(s) in current directory: + Found {foundFiles.length} P8 file(s) in ./ or ./ + {PROJECT_CONFIG_DIR}: @@ -696,11 +711,6 @@ function P8InputPhase({ onSubmit={handleTeamIdSubmit} /> - {!prefilledTeamId && ( - - Press 't' to open Team ID page in browser - - )} )} @@ -731,10 +741,11 @@ export const ApnsKeyAcquisitionTask: React.FC<{ pushKey: { apnsKeyId: string; apnsKeyP8: string; teamId: string; teamName?: string }; }) => { const p8FileName = `AuthKey_${result.pushKey.apnsKeyId}.p8`; - const projectP8Path = path.join(projectPath, p8FileName); + const projectP8Path = path.join(projectPath, PROJECT_CONFIG_DIR, p8FileName); let savedPath: string | null = null; try { + fs.mkdirSync(path.dirname(projectP8Path), { recursive: true }); fs.writeFileSync(projectP8Path, result.pushKey.apnsKeyP8, { encoding: 'utf-8', mode: 0o600, @@ -742,14 +753,17 @@ export const ApnsKeyAcquisitionTask: React.FC<{ savedPath = projectP8Path; } catch { try { - const fallbackPath = path.join(process.cwd(), p8FileName); + const fallbackPath = path.join(process.cwd(), PROJECT_CONFIG_DIR, p8FileName); + fs.mkdirSync(path.dirname(fallbackPath), { recursive: true }); fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, { encoding: 'utf-8', mode: 0o600, }); savedPath = fallbackPath; } catch { - setError('Failed to write APNS key file. Check directory permissions and try again.'); + setError( + `Failed to write APNS key file under ${PROJECT_CONFIG_DIR}. Check directory permissions and try again.`, + ); setPhase('error'); return; } @@ -835,33 +849,54 @@ export const ApnsKeyAcquisitionTask: React.FC<{ ); }; -function FirebaseAuthPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { +function FirebaseAuthPhase({ + onCancel, + authUrl, +}: { + onCancel: () => void; + authUrl?: string | null; +}): React.ReactElement { useCancelInput(onCancel); + const reopenLink = authUrl ? formatTerminalHyperlink(authUrl, 'Open authentication URL') : null; return ( - - - Firebase Authentication - - - - - - Authenticating with Firebase... - - - A browser window will open for authentication - - - Esc/Ctrl+C to cancel + + + + Firebase Authentication + + + + + + Authenticating with Firebase... + + + A browser window will open for authentication + + {reopenLink ? ( + + If browser was closed, reopen this URL: + {reopenLink} + + ) : null} + + Esc/Ctrl+C to cancel + + {authUrl ? ( + + Direct URL: + {authUrl} + + ) : null} ); } @@ -952,7 +987,12 @@ export const FirebaseProjectSelectionTask: React.FC<{ const [phase, setPhase] = useState('authenticating'); const [projects, setProjects] = useState([]); const [error, setError] = useState(null); + const [authUrl, setAuthUrl] = useState(null); const downloaderRef = useRef(null); + const cancelAuthentication = useCallback(() => { + downloaderRef.current?.cancelAuthentication('Firebase project selection cancelled'); + onCancel(); + }, [onCancel]); useEffect(() => { if (phase !== 'authenticating') { @@ -963,9 +1003,16 @@ export const FirebaseProjectSelectionTask: React.FC<{ const authenticateAndSelect = async () => { try { + setAuthUrl(null); downloaderRef.current = downloaderRef.current ?? new FirebaseDownloader(); const downloader = downloaderRef.current; - const authResult = await downloader.authenticate(openBrowser); + const authResult = await downloader.authenticate((url) => { + if (cancelled) { + return; + } + setAuthUrl(url); + void openBrowser(url); + }); if (!authResult.success) { throw new Error(authResult.error || 'Firebase authentication failed'); } @@ -1001,11 +1048,12 @@ export const FirebaseProjectSelectionTask: React.FC<{ return () => { cancelled = true; + downloaderRef.current?.cancelAuthentication('Firebase project selection cancelled'); }; }, [onComplete, phase, preferredProjectId]); if (phase === 'authenticating') { - return ; + return ; } if (phase === 'select_project') { @@ -1013,7 +1061,7 @@ export const FirebaseProjectSelectionTask: React.FC<{ onComplete(project)} - onCancel={onCancel} + onCancel={cancelAuthentication} /> ); } @@ -1023,9 +1071,10 @@ export const FirebaseProjectSelectionTask: React.FC<{ error={error || 'Unknown Firebase project selection error'} onRetry={() => { setError(null); + setAuthUrl(null); setPhase('authenticating'); }} - onCancel={onCancel} + onCancel={cancelAuthentication} /> ); }; @@ -1119,9 +1168,9 @@ function FirebaseUploadPhase({ Steps: - {FIREBASE_UPLOAD_STEPS.map((step) => ( - - {FIREBASE_UPLOAD_STEPS.indexOf(step) + 1}. {step} + {FIREBASE_UPLOAD_STEPS.map((step, index) => ( + + {index + 1}. {step} ))} diff --git a/src/ui/utils/terminalHyperlink.ts b/src/ui/utils/terminalHyperlink.ts new file mode 100644 index 0000000..12d11e7 --- /dev/null +++ b/src/ui/utils/terminalHyperlink.ts @@ -0,0 +1,26 @@ +const OSC_8 = '\u001B]8;;'; +const ST = '\u0007'; + +function sanitizeTerminalValue(value: string): string { + return Array.from(value) + .filter((character) => { + const code = character.charCodeAt(0); + return code >= 0x20 && code !== 0x7f; + }) + .join(''); +} + +/** + * Format a clickable terminal hyperlink using OSC 8 escape sequences. + * Falls back to plain label if URL or label are empty after sanitization. + */ +export function formatTerminalHyperlink(url: string, label: string): string { + const safeUrl = sanitizeTerminalValue(url); + const safeLabel = sanitizeTerminalValue(label); + + if (!safeUrl || !safeLabel) { + return label; + } + + return `${OSC_8}${safeUrl}${ST}${safeLabel}${OSC_8}${ST}`; +} From 9afb27e8874fbf278ac18a549eeb06fd2357da09 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Thu, 19 Feb 2026 21:49:07 -0800 Subject: [PATCH 11/30] feat: restructure install workflow and normalize streaming text updates --- src/commands/skill/index.tsx | 2 + src/lib/__tests__/skills.test.ts | 77 +++ src/lib/commands/skills.ts | 2 +- src/lib/constants/system-prompt.ts | 2 +- src/lib/embedded-skills.ts | 405 ++++++++------- src/lib/executor.ts | 8 + .../__tests__/claude-executor.test.ts | 62 +++ .../__tests__/codex-executor.test.ts | 10 + .../__tests__/cursor-executor.test.ts | 34 ++ .../__tests__/gemini-executor.test.ts | 37 ++ .../__tests__/opencode-executor.test.ts | 37 ++ .../executors/__tests__/stream-delta.test.ts | 24 + src/lib/executors/base-executor.ts | 28 +- src/lib/executors/claude-executor.ts | 14 +- src/lib/executors/cli-process-manager.ts | 4 +- src/lib/executors/codex-executor.ts | 10 +- src/lib/executors/copilot-executor.ts | 5 +- src/lib/executors/cursor-executor.ts | 34 +- src/lib/executors/gemini-executor.ts | 22 +- src/lib/executors/opencode-executor.ts | 14 + src/lib/executors/stream-delta.ts | 38 ++ src/lib/skills.ts | 245 ++++++--- src/lib/skills/install/SKILL.md | 255 +++------ src/lib/skills/project-build/SKILL.md | 167 ++++++ src/lib/utils/__tests__/stream-text.test.ts | 14 + src/lib/utils/stream-text.ts | 17 + src/ui/chat/ChatApp.tsx | 23 +- src/ui/chat/components/MessageList.tsx | 133 +---- .../__tests__/useMessageStreaming.test.ts | 12 + src/ui/chat/hooks/useChatActions.ts | 1 + src/ui/chat/hooks/useMessageSending.ts | 67 ++- src/ui/chat/hooks/useMessageStreaming.ts | 232 +++++---- src/ui/chat/hooks/useOverlays.ts | 41 +- src/ui/components/ChatMessageList.tsx | 187 +++++++ src/ui/components/InstallPreparationUI.tsx | 485 +++++++++++++++++- .../install-preparation-flow.test.ts | 62 +++ .../components/install-preparation-tasks.ts | 44 +- src/ui/utils/InlineMarkdownRenderer.tsx | 10 +- .../inline-markdown-renderer.test.ts | 19 + tests/e2e/cli.test.ts | 6 +- tests/e2e/command-output.test.ts | 3 +- 41 files changed, 2168 insertions(+), 724 deletions(-) create mode 100644 src/lib/executors/__tests__/stream-delta.test.ts create mode 100644 src/lib/executors/stream-delta.ts create mode 100644 src/lib/skills/project-build/SKILL.md create mode 100644 src/lib/utils/__tests__/stream-text.test.ts create mode 100644 src/lib/utils/stream-text.ts create mode 100644 src/ui/chat/hooks/__tests__/useMessageStreaming.test.ts create mode 100644 src/ui/components/ChatMessageList.tsx create mode 100644 src/ui/utils/__tests__/inline-markdown-renderer.test.ts diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 867a49f..f3e775e 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -73,6 +73,8 @@ async function runInstallPreparation( ({ success: true })} + onRunInstallSkill={async () => ({ success: true })} onComplete={(context) => { unmount(); resolve(context); diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index 73f9fd9..fac4eb7 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -27,6 +27,7 @@ describe('AVAILABLE_SKILLS', () => { const { getAvailableSkillTypes, getSkillInfo } = require('../skills'); const skillTypes = getAvailableSkillTypes(); expect(skillTypes).toContain('install'); + expect(skillTypes).toContain('project-build'); const installSkill = getSkillInfo('install'); expect(installSkill?.isLocal).toBe(true); @@ -319,6 +320,8 @@ describe('preparationContext in install skill', () => { // Verify pre-configured setup section is included expect(prompt).toContain('Pre-configured Setup'); expect(prompt).toContain('Test Project'); + expect(prompt).toContain('Detected project type: react-native (iOS/Android)'); + expect(prompt).toContain('Install Step Verification'); // Verify Clix project info expect(prompt).toContain('Clix Project'); @@ -330,6 +333,13 @@ describe('preparationContext in install skill', () => { expect(prompt).toContain('my-firebase-project'); expect(prompt).toContain('Android (google-services.json)'); expect(prompt).toContain('iOS (GoogleService-Info.plist)'); + expect(prompt).toContain('Sender Config (App Push): ✓ configured'); + + // Verify APNS info + expect(prompt).toContain('APNS'); + expect(prompt).toContain('Key ID: ABC1234567'); + expect(prompt).toContain('Team ID: TEAM123'); + expect(prompt).toContain('Registered with Firebase: ✓ configured'); // Verify iOS info expect(prompt).toContain('iOS'); @@ -452,6 +462,73 @@ describe('preparationContext in install skill', () => { // Pre-configured Setup section should not appear without context expect(prompt).not.toContain('### Clix Project'); }); + + test('install skill should execute SDK integration prompt content', async () => { + const { getSkillPrompt } = await import('../skills'); + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + }); + + expect(prompt).toContain('# Clix SDK Autonomous Integration'); + expect(prompt).toContain('SDK code integration'); + }); + + test('project-build skill should execute project-build prompt content', async () => { + const { getSkillPrompt } = await import('../skills'); + + const prompt = await getSkillPrompt('project-build', { + projectPath: '/test/project', + }); + + expect(prompt).toContain('# Project Build'); + expect(prompt).toContain('Inputs from `/install` (already provided)'); + }); + + test('install skill should infer target platform from preparationContext project type', async () => { + const { getSkillPrompt } = await import('../skills'); + + const preparationContext = { + projectPath: '/test/project', + config: { + version: 2 as const, + member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, + organization: { id: 'org-1', name: 'Test Org' }, + project: { id: 'project-1', name: 'Flutter Project' }, + linkedAt: '2024-01-01T00:00:00Z', + }, + projectType: { framework: 'flutter' as const, target: 'ios-android' as const }, + firebase: { + needed: false, + configured: false, + androidConfigured: false, + iosConfigured: false, + senderConfigConfigured: false, + projectId: undefined, + }, + ios: { + needed: false, + bundleId: undefined, + teamId: undefined, + appGroupId: undefined, + entitlementsConfigured: false, + nseConfigured: false, + }, + apns: { + needed: false, + registeredWithFirebase: false, + }, + missing: [], + ready: true, + }; + + const prompt = await getSkillPrompt('install', { + projectPath: '/test/project', + preparationContext, + }); + + expect(prompt).toContain('Target platform: flutter'); + }); }); describe('error handling', () => { diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts index b9691f8..3954540 100644 --- a/src/lib/commands/skills.ts +++ b/src/lib/commands/skills.ts @@ -76,7 +76,7 @@ function generateSkillCommands(): Command[] { commands.push( createLocalSkillCommand( 'install', - 'Autonomous SDK installation with file modifications', + 'Autonomous SDK integration with automatic file modifications', 'install', ), ); diff --git a/src/lib/constants/system-prompt.ts b/src/lib/constants/system-prompt.ts index 15d8eb6..4ad9eb2 100644 --- a/src/lib/constants/system-prompt.ts +++ b/src/lib/constants/system-prompt.ts @@ -65,7 +65,7 @@ Platform-specific configs to verify: - Verify placeholder replacement before completion ## Available Slash Commands -- /install - Autonomous SDK installation with automatic file modifications +- /install - Install workflow: required setup, project build, and SDK integration - /integration - SDK integration guide with step-by-step instructions - /event-tracking - Event tracking setup - /user-management - User management diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index cf615b8..f7f0b60 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -1085,212 +1085,261 @@ Use MCP to fetch the exact signatures per platform, then: - Campaign audiences can filter on them - Messages can use \`user.*\` personalization `, - 'local-install': `# Clix SDK Autonomous Installation + 'local-project-build': `--- +name: project-build +description: Build the project using project type and install verification context passed from /install. Validate the provided project type with quick file checks, run the build, and on failure automatically apply fixes and retry without asking for user approval. +--- -You are an autonomous AI agent that installs and integrates the Clix mobile push notification SDK. +# Project Build -## Core Directive +Use the context already collected by \`/install\` to run a reliable build and handle failures autonomously. -**MODIFY FILES DIRECTLY** - You have full permission to create, edit, and delete files. Use Write and Edit tools immediately without asking for permission or confirmation. +## Inputs from \`/install\` (already provided) -## Supported Platforms & Documentation +Assume these inputs are available in the prompt and use them as the primary source of truth: -- iOS: https://docs.clix.so/sdk-quickstart-ios -- Android: https://docs.clix.so/sdk-quickstart-android -- React Native: https://docs.clix.so/sdk-quickstart-react-native -- Flutter: https://docs.clix.so/sdk-quickstart-flutter +- Project path +- Target platform +- Detected project type (framework + target) +- Install-step verification summary (what was completed and validated) +- Firebase/APNS/iOS setup status -## Platform Detection Priority +Do not rerun previous setup tasks unless file evidence conflicts. -1. Check for cross-platform frameworks first: - - \`package.json\` with React Native/Expo dependencies - - \`pubspec.yaml\` for Flutter -2. Then check native platforms: - - **iOS (in order of priority):** - 1. \`Package.swift\` with iOS platform target → **Pure SPM iOS project** - - Look for patterns: \`.iOS\`, \`.iOS(.v13)\`, \`.iOS(.v14)\`, \`platforms: [.iOS]\`, \`platforms: [.iOS(.v13)]\` - 2. \`Podfile\` exists → **CocoaPods project** - 3. \`*.xcodeproj/project.pbxproj\` containing \`XCRemoteSwiftPackageReference\` → **Xcode with SPM** - 4. \`*.xcodeproj\` or \`*.xcworkspace\` only → **Suggest SPM** (modern, recommended) - - Note: \`Package.swift\` without iOS platform is likely a server-side Swift or CLI project, not iOS - - \`build.gradle\` or \`AndroidManifest.xml\` for Android +## Step 1: Use Provided Project Type, Then Validate Quickly -## Installation Steps +Start from the provided project type/target platform. Then run a quick validation scan in parallel using indicator files: -### 1. Detect Platform -Analyze project files to identify the platform. +| Indicator Files | Project Type | +|---|---| +| \`Podfile\` + \`*.xcworkspace\` | iOS native (CocoaPods) | +| \`*.xcodeproj\` + \`Package.swift\` OR \`*.xcodeproj\` with no \`Podfile\` | iOS native (SPM) | +| \`build.gradle\` or \`build.gradle.kts\` (at root or \`app/\`) | Android | +| \`package.json\` with \`"react-native"\` dependency + \`android/\` and \`ios/\` dirs | React Native | +| \`package.json\` with \`"expo"\` dependency OR \`app.json\` with \`"expo"\` key | Expo | +| \`pubspec.yaml\` | Flutter | -### 2. Install SDK Package +If the quick validation conflicts with the provided project type, use file-evidence as source of truth, mention the mismatch in output, and continue. -**React Native:** -- Add \`@clix-so/react-native-sdk\` to package.json -- Run npm/yarn install - -**iOS (Dependency Manager Detection):** - -First, detect the dependency manager being used: - -1. **Pure SPM iOS Project** (has \`Package.swift\` with iOS platform target): - - First verify Package.swift contains iOS platform (\`.iOS\`, \`.iOS(.v13)\`, \`platforms: [.iOS]\`, etc.) - - If no iOS platform found, this is likely a server-side Swift project - skip iOS installation - - Read Package.swift and add to dependencies array: - \`\`\`swift - .package(url: "https://github.com/clix-so/clix-ios-sdk.git", from: "1.0.0") - \`\`\` - - Add to target dependencies: - \`\`\`swift - .product(name: "Clix", package: "clix-ios-sdk") - \`\`\` - - Run \`swift package resolve\` - -2. **CocoaPods Project** (has \`Podfile\`): - - Add to Podfile: - \`\`\`ruby - pod 'Clix', :git => 'https://github.com/clix-so/clix-ios-sdk.git' - \`\`\` - - Run: \`cd ios && pod install\` - -3. **Xcode Project with SPM** (has \`project.pbxproj\` with \`XCRemoteSwiftPackageReference\`): - - Inform user to add via Xcode: File > Add Package Dependencies - - URL: \`https://github.com/clix-so/clix-ios-sdk\` - - Note: Direct .pbxproj modification is complex; prefer Xcode UI - -4. **Bare Xcode Project** (only \`*.xcodeproj\` or \`*.xcworkspace\`): - - Recommend SPM: Guide user to add via Xcode (File > Add Package Dependencies) - - Alternative: Create Podfile and use CocoaPods - -**Android:** -- Add to build.gradle +## Step 2: Determine Build Command -**Flutter:** -- Add to pubspec.yaml -- Run flutter pub get +### iOS simulator discovery (xcodebuild only) -### 3. Create/Modify Files Directly +When using \`xcodebuild\` (iOS native CocoaPods/SPM), discover available simulators before constructing the command: -**React Native:** -- Create initialization module or add to existing entry file -- Update constants file with CLIX_PROJECT_ID and CLIX_PUBLIC_API_KEY exports -- Add initialization call in app entry point (app/_layout.tsx or index.js) -- Add configuration to environment files with placeholders +\`\`\`bash +xcrun simctl list devices available -j +\`\`\` -**iOS:** -- Modify AppDelegate to initialize SDK -- Add Info.plist entries -- Note: Xcode capabilities (Push Notifications, Background Modes) require manual IDE steps +Choose simulator priority: +1. Latest iOS runtime +2. Highest iPhone model number in that runtime (prefer base iPhone model over Pro/Plus/Max) +3. Fallback to any iPad if no iPhone exists -**Android:** -- Modify MainActivity or Application class -- Update AndroidManifest.xml with permissions -- Verify Firebase configuration (see step 6) +Use destination: +\`\`\` +-destination 'platform=iOS Simulator,name=' +\`\`\` -**Flutter:** -- Modify main.dart to initialize SDK -- Update platform-specific files as needed -- Verify Firebase configuration (see step 6) +If simulator discovery fails, inform the user and suggest installing simulators via Xcode. + +### Default build commands by project type + +**iOS native (CocoaPods)**: +\`\`\` +pod install --project-directory= +xcodebuild -workspace .xcworkspace -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build +\`\`\` +- Use \`xcodebuild -workspace .xcworkspace -list\` to list schemes. + +**iOS native (SPM)**: +\`\`\` +xcodebuild -project .xcodeproj -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build +\`\`\` +- Use \`xcodebuild -project .xcodeproj -list\` to list schemes. -### 4. Use Placeholders for Secrets +**Android**: +\`\`\` +./gradlew assembleDebug +\`\`\` -Use \`YOUR_CLIX_PROJECT_ID\` and \`YOUR_CLIX_PUBLIC_API_KEY\` as placeholders. Inform user to replace with actual credentials from https://console.clix.so/ +**React Native**: +\`\`\` +npx react-native run-ios +# or +npx react-native run-android +\`\`\` -### 5. Run Post-Installation Commands +**Expo**: +\`\`\` +npx expo run:ios +# or +npx expo run:android +\`\`\` -Execute necessary commands: -- \`npm install\` or \`yarn install\` after package.json changes -- \`cd ios && pod install\` for iOS dependencies -- \`flutter pub get\` for Flutter +**Flutter**: +\`\`\` +flutter build ios --debug --no-codesign +# or +flutter build apk --debug +\`\`\` -### 6. Verify Firebase Configuration +### Override detection -For push notifications to work, Firebase must be properly configured: +Also check for custom build commands: +- \`CLAUDE.md\` +- \`Makefile\` (\`build\` target) +- \`package.json\` scripts (\`build\`, \`ios\`, \`android\`) +- \`Justfile\` / \`Taskfile.yml\` +- \`fastlane/Fastfile\` -**Android (google-services.json):** -- Expected locations: - - Standard Android: \`app/google-services.json\` - - React Native/Flutter: \`android/app/google-services.json\` -- Download from Firebase Console > Project Settings > Your apps > Android app -- Verify package name matches your AndroidManifest.xml +Prefer custom commands when valid. -**iOS (GoogleService-Info.plist):** -- Expected locations: - - React Native: \`ios/GoogleService-Info.plist\` - - Flutter: \`ios/Runner/GoogleService-Info.plist\` - - Native iOS: \`/GoogleService-Info.plist\` -- Download from Firebase Console > Project Settings > Your apps > iOS app -- Verify bundle ID matches your Xcode project - -**Validation:** -- Check if files exist in correct locations -- Verify JSON/plist structure is valid -- Confirm project IDs match between platforms (for cross-platform apps) - -Use \`/install\` in interactive mode to run required Firebase setup tasks. - -## Automation Rules - -✅ **DO:** -- Use Write tool to create new files immediately -- Use Edit tool to modify existing files immediately -- Use Bash tool to run installation commands -- Proceed autonomously through all steps -- Report what was done after completion - -❌ **DO NOT:** -- Ask for permission or confirmation -- Say "you should" or "please add" - just do it -- Provide manual steps for code changes - make the changes -- Wait for user input (except for IDE-only tasks) - -## IDE-Only Manual Steps - -Only these require user action: -- Xcode: Adding capabilities, configuring entitlements -- Android Studio: Firebase setup UI, capability configuration -- Building and running the project - -For these, provide brief instructions but don't wait for confirmation. - -### iOS Notification Service Extension (Recommended) - -For rich push notifications (images, buttons), create a Notification Service Extension: - -1. In Xcode: File > New > Target > Notification Service Extension -2. Add Clix SDK to the extension target: - - **CocoaPods**: Add \`target 'YourExtension' do pod 'Clix' end\` to Podfile, then \`pod install\` - - **SPM**: Add Clix package to extension target in Xcode (General > Frameworks) -3. Implement NotificationService.swift: - \`\`\`swift - import UserNotifications - import Clix - - class NotificationService: ClixNotificationServiceExtension { - override init() { - super.init() - register(projectId: "YOUR_PROJECT_ID") - } - - override func didReceive(_ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - super.didReceive(request, withContentHandler: contentHandler) - } - - override func serviceExtensionTimeWillExpire() { - super.serviceExtensionTimeWillExpire() - } - } - \`\`\` -4. Add App Groups capability to both main app and extension (same group ID: \`group.clix.{BUNDLE_ID}\`) -5. For Xcode 15+: Set \`ENABLE_USER_SCRIPT_SANDBOXING\` to "No" in extension's Build Settings - -For detailed setup, run \`clix install\` or \`/install\` in interactive mode. +## Step 3: Finalize Build Plan and Execute + +Before running build, summarize internally: +1. Project type from \`/install\` +2. Install-step verification summary (completed + missing) +3. Build command that will run +4. Simulator/device info (for iOS \`xcodebuild\`) + +Do not ask for approval. Proceed immediately with the best command. + +## Step 4: Execute Build + +Run the confirmed command with Bash (timeout up to \`600000ms\`). + +After build: +- On success: provide concise success summary and key output artifacts. +- On failure: provide error summary (first root-cause lines + failing command). + +## Step 5: On Build Failure, Auto-Fix and Retry + +After a failed build, do not stop for approval. + +- Apply minimal targeted fixes automatically. +- Retry build after each fix. +- Repeat until build succeeds or a hard blocker remains (missing secrets, account permissions, unavailable external service). +- For hard blockers, stop and clearly report blocker + exact action required from user. + +## Step 6: Build Failure Checklist to Show User + +Provide a quick checklist when build fails (including intermediate retries): + +**Common** +- Dependencies installed and lockfiles are in sync (\`bun install\`/\`npm install\`/\`yarn\`/\`pnpm install\`) +- Clean stale caches/build artifacts +- Correct CLI/toolchain versions (Node, Java, Flutter, Xcode CLT) + +**iOS** +- Correct workspace/project and scheme +- Simulator exists and is bootable +- CocoaPods installed (\`pod install\`) when using pods +- Signing/capabilities/provisioning consistency +- Firebase plist, entitlements, and NSE files/targets linked correctly + +**Android** +- Gradle sync and compatible JDK/SDK versions +- \`google-services.json\` path and Google Services plugin setup +- Build variant/task matches project structure + +**React Native / Expo** +- \`node_modules\` installed +- iOS pods installed (\`cd ios && pod install\`) for iOS build paths +- Correct run command for target platform + +**Flutter** +- \`flutter pub get\` completed +- iOS pods/Xcode toolchain healthy for iOS builds +- Target device/emulator availability +`, + 'local-install': `# Clix SDK Autonomous Integration + +You are an autonomous AI agent that integrates the Clix mobile SDK into an existing project. + +## Scope + +This prompt is only for **SDK code integration**. + +Do not perform or re-describe setup steps that are already handled by \`/install\` preparation tasks: +- Firebase configuration files +- APNS key registration in Firebase +- Firebase service account sender config +- iOS entitlements setup +- Notification Service Extension setup + +## Core Directive + +**MODIFY FILES DIRECTLY** - Create/edit files immediately without asking for confirmation. + +## Supported Platforms & Docs + +- iOS: https://docs.clix.so/sdk-quickstart-ios +- Android: https://docs.clix.so/sdk-quickstart-android +- React Native: https://docs.clix.so/sdk-quickstart-react-native +- Flutter: https://docs.clix.so/sdk-quickstart-flutter + +## Required Work + +### 1. Detect project platform and dependency manager + +Detect platform from project files, then apply the matching integration path: +- iOS native (SPM / CocoaPods) +- Android native (Gradle) +- React Native / Expo (npm, yarn, pnpm, or bun) +- Flutter (pubspec) + +### 2. Add Clix SDK dependency + +Apply dependency changes directly in project files. + +- iOS SPM: add Clix package and product dependency +- iOS CocoaPods: add \`pod 'Clix'\` and run pod install +- Android: add Clix dependency in Gradle +- React Native: add \`@clix-so/react-native-sdk\` +- Flutter: add \`clix_flutter_sdk\` + +### 3. Integrate SDK initialization in app code + +Add initialization code in the real app entry path for the detected platform. +Use placeholders when credentials are missing: +- \`YOUR_CLIX_PROJECT_ID\` +- \`YOUR_CLIX_PUBLIC_API_KEY\` + +### 4. Run post-change package/install commands + +Run only commands needed for dependency resolution/build graph update, for example: +- \`npm install\` / \`yarn install\` / \`pnpm install\` / \`bun install\` +- \`pod install\` (if Podfile changed) +- \`flutter pub get\` + +### 5. Verify integration artifacts + +Verify that: +- dependency declarations were added correctly +- initialization code is present in app startup path +- imports and references resolve +- no duplicate initialization exists + +## Rules + +✅ Do: +- apply concrete file changes +- keep edits minimal and project-specific +- preserve existing code style + +❌ Do not: +- redo Firebase/APNS/Entitlements/NSE tasks +- ask the user to manually apply code edits +- add speculative abstractions ## Output Format -After completion, report: -✓ Files created/modified (with paths) -✓ Commands executed -✓ Placeholders that need replacement -✓ Any IDE-only steps required +At completion, report: +- modified files +- commands executed +- placeholders that still need real values +- any hard blockers (if any) `, 'local-doctor': `# Clix SDK Doctor diff --git a/src/lib/executor.ts b/src/lib/executor.ts index f435d91..eba4e14 100644 --- a/src/lib/executor.ts +++ b/src/lib/executor.ts @@ -10,9 +10,17 @@ export interface ExecuteOptions { interactive?: boolean; } +export type AgentTextStreamMode = 'append' | 'replace'; + export interface AgentMessage { type: 'text' | 'tool_call' | 'tool_result' | 'error' | 'complete'; content: string; + /** + * Streaming mode for text chunks. + * - append: concatenate chunk to current content + * - replace: replace current content with this chunk + */ + streamMode?: AgentTextStreamMode; metadata?: Record; } diff --git a/src/lib/executors/__tests__/claude-executor.test.ts b/src/lib/executors/__tests__/claude-executor.test.ts index fa51755..323ca41 100644 --- a/src/lib/executors/__tests__/claude-executor.test.ts +++ b/src/lib/executors/__tests__/claude-executor.test.ts @@ -36,6 +36,34 @@ describe('ClaudeExecutor', () => { expect(mockCommandExists).toHaveBeenCalledWith('claude'); }); }); + + describe('buildArgs', () => { + test('should enable bypass permission mode and dangerous skip permissions', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing protected method + const args = (executor as any).buildArgs('test prompt', { oneShot: true }); + + expect(args).toContain('--allow-dangerously-skip-permissions'); + expect(args).toContain('--permission-mode'); + expect(args).toContain('bypassPermissions'); + }); + + test('should include no-session-persistence in one-shot mode', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing protected method + const args = (executor as any).buildArgs('test prompt', { oneShot: true }); + + expect(args).toContain('--no-session-persistence'); + }); + + test('should include resume flag in chat mode when session exists', () => { + executor.setSessionId('session-123'); + // biome-ignore lint/suspicious/noExplicitAny: Testing protected method + const args = (executor as any).buildArgs('test prompt', { oneShot: false }); + + expect(args).toContain('--resume'); + expect(args).toContain('session-123'); + expect(args).not.toContain('--no-session-persistence'); + }); + }); }); describe('ClaudeExecutor session management', () => { @@ -163,3 +191,37 @@ describe('ClaudeExecutor message mapping', () => { }); }); }); + +describe('ClaudeExecutor stream mode mapping', () => { + test('emits append-only cumulative deltas, including rewritten snapshots', () => { + const executor = new ClaudeExecutor() as unknown as { + extractTextDelta: ( + textContent: string, + msg: ClaudeCLIMessage, + ) => { + type: string; + content: string; + streamMode?: 'append' | 'replace'; + } | null; + }; + + const msg: ClaudeCLIMessage = { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'placeholder' }], + }, + }; + + const first = executor.extractTextDelta('Hello', msg); + expect(first?.content).toBe('Hello'); + expect(first?.streamMode).toBe('append'); + + const second = executor.extractTextDelta('Hello world', msg); + expect(second?.content).toBe(' world'); + expect(second?.streamMode).toBe('append'); + + const rewrite = executor.extractTextDelta('Rewritten output', msg); + expect(rewrite?.content).toBe('\nRewritten output'); + expect(rewrite?.streamMode).toBe('append'); + }); +}); diff --git a/src/lib/executors/__tests__/codex-executor.test.ts b/src/lib/executors/__tests__/codex-executor.test.ts index 66d331b..3611dfe 100644 --- a/src/lib/executors/__tests__/codex-executor.test.ts +++ b/src/lib/executors/__tests__/codex-executor.test.ts @@ -36,6 +36,16 @@ describe('CodexExecutor', () => { expect(mockCommandExists).toHaveBeenCalledWith('codex'); }); }); + + describe('buildArgs', () => { + test('should enable dangerous bypass flag by default', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing protected method + const args = (executor as any).buildArgs('test prompt', { oneShot: true }); + + expect(args).toContain('--dangerously-bypass-approvals-and-sandbox'); + expect(args).not.toContain('--sandbox'); + }); + }); }); describe('CodexExecutor message mapping', () => { diff --git a/src/lib/executors/__tests__/cursor-executor.test.ts b/src/lib/executors/__tests__/cursor-executor.test.ts index b5ae039..b56198b 100644 --- a/src/lib/executors/__tests__/cursor-executor.test.ts +++ b/src/lib/executors/__tests__/cursor-executor.test.ts @@ -151,3 +151,37 @@ describe('Cursor CLI message format', () => { expect(msg.result).toBe('Error: Something went wrong'); }); }); + +describe('CursorExecutor stream mode mapping', () => { + test('emits append-only cumulative deltas, including rewritten snapshots', () => { + const executor = new CursorExecutor() as unknown as { + extractTextDelta: ( + textContent: string, + msg: CursorCLIMessage, + ) => { + type: string; + content: string; + streamMode?: 'append' | 'replace'; + } | null; + }; + + const msg: CursorCLIMessage = { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'placeholder' }], + }, + }; + + const first = executor.extractTextDelta('Hello', msg); + expect(first?.content).toBe('Hello'); + expect(first?.streamMode).toBe('append'); + + const second = executor.extractTextDelta('Hello world', msg); + expect(second?.content).toBe(' world'); + expect(second?.streamMode).toBe('append'); + + const rewrite = executor.extractTextDelta('Completely different', msg); + expect(rewrite?.content).toBe('\nCompletely different'); + expect(rewrite?.streamMode).toBe('append'); + }); +}); diff --git a/src/lib/executors/__tests__/gemini-executor.test.ts b/src/lib/executors/__tests__/gemini-executor.test.ts index f0d8fd2..7f52acc 100644 --- a/src/lib/executors/__tests__/gemini-executor.test.ts +++ b/src/lib/executors/__tests__/gemini-executor.test.ts @@ -195,3 +195,40 @@ describe('GeminiExecutor message mapping', () => { }); }); }); + +describe('GeminiExecutor stream mode mapping', () => { + test('maps both snapshot and delta events to append-only cumulative output', () => { + const executor = new GeminiExecutor() as unknown as { + processStreamData: ( + data: unknown, + context: { hasYieldedText: boolean; assistantContent: string; count: number }, + ) => { type: string; streamMode?: 'append' | 'replace'; content: string } | null; + }; + + const fullUpdate = executor.processStreamData( + { + type: 'message', + timestamp: '2025-01-14T00:00:00.000Z', + role: 'assistant', + content: 'Hello world', + delta: false, + } satisfies GeminiCLIMessage, + { hasYieldedText: false, assistantContent: '', count: 1 }, + ); + expect(fullUpdate?.type).toBe('text'); + expect(fullUpdate?.streamMode).toBe('append'); + + const deltaUpdate = executor.processStreamData( + { + type: 'message', + timestamp: '2025-01-14T00:00:00.000Z', + role: 'assistant', + content: ' world', + delta: true, + } satisfies GeminiCLIMessage, + { hasYieldedText: true, assistantContent: 'Hello', count: 2 }, + ); + expect(deltaUpdate?.type).toBe('text'); + expect(deltaUpdate?.streamMode).toBe('append'); + }); +}); diff --git a/src/lib/executors/__tests__/opencode-executor.test.ts b/src/lib/executors/__tests__/opencode-executor.test.ts index 81e820b..d00ec2f 100644 --- a/src/lib/executors/__tests__/opencode-executor.test.ts +++ b/src/lib/executors/__tests__/opencode-executor.test.ts @@ -36,6 +36,43 @@ describe('OpenCodeExecutor', () => { expect(mockCommandExists).toHaveBeenCalledWith('opencode'); }); }); + + describe('permission env', () => { + test('should set allow-all OpenCode permission when env is not provided', () => { + const original = process.env.OPENCODE_PERMISSION; + process.env.OPENCODE_PERMISSION = undefined; + + try { + // biome-ignore lint/suspicious/noExplicitAny: Testing protected method + const env = (executor as any).getSpawnEnv(); + expect(env).toBeDefined(); + expect(env.OPENCODE_PERMISSION).toBe('{"*":"allow"}'); + } finally { + if (original === undefined) { + process.env.OPENCODE_PERMISSION = undefined; + } else { + process.env.OPENCODE_PERMISSION = original; + } + } + }); + + test('should not override explicit OpenCode permission env', () => { + const original = process.env.OPENCODE_PERMISSION; + process.env.OPENCODE_PERMISSION = '{"bash":"ask"}'; + + try { + // biome-ignore lint/suspicious/noExplicitAny: Testing protected method + const env = (executor as any).getSpawnEnv(); + expect(env).toBeUndefined(); + } finally { + if (original === undefined) { + process.env.OPENCODE_PERMISSION = undefined; + } else { + process.env.OPENCODE_PERMISSION = original; + } + } + }); + }); }); describe('OpenCode CLI message format', () => { diff --git a/src/lib/executors/__tests__/stream-delta.test.ts b/src/lib/executors/__tests__/stream-delta.test.ts new file mode 100644 index 0000000..df19aab --- /dev/null +++ b/src/lib/executors/__tests__/stream-delta.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test'; +import { extractCumulativeDelta } from '../stream-delta'; + +describe('extractCumulativeDelta', () => { + test('returns full text for first snapshot', () => { + expect(extractCumulativeDelta('', 'Hello')).toBe('Hello'); + }); + + test('returns appended suffix for prefix snapshots', () => { + expect(extractCumulativeDelta('Hello', 'Hello world')).toBe(' world'); + }); + + test('returns suffix after suffix-prefix overlap', () => { + expect(extractCumulativeDelta('abc123', '123xyz')).toBe('xyz'); + }); + + test('returns empty string for rewind snapshots already contained in previous', () => { + expect(extractCumulativeDelta('Hello world', 'Hello')).toBe(''); + }); + + test('returns a newline-prefixed block for full rewrites', () => { + expect(extractCumulativeDelta('Hello world', 'Rewritten output')).toBe('\nRewritten output'); + }); +}); diff --git a/src/lib/executors/base-executor.ts b/src/lib/executors/base-executor.ts index ce7befa..ee04d65 100644 --- a/src/lib/executors/base-executor.ts +++ b/src/lib/executors/base-executor.ts @@ -148,6 +148,14 @@ export abstract class BaseExecutor implements AgentExecutor { return debug === '1' || debug === 'true' || debug === '*'; } + /** + * Optional environment overrides for spawned CLI process. + * Return undefined to inherit parent process environment as-is. + */ + protected getSpawnEnv(_options?: ExecuteOptions): NodeJS.ProcessEnv | undefined { + return undefined; + } + protected getPreparedPrompt(prompt: string, options?: ExecuteOptions): string { const shouldInject = options?.oneShot || !this.systemPromptInjected; if (!shouldInject) { @@ -215,6 +223,20 @@ ${prompt}`; return { type: 'error', content: errorMessage }; } + private updateAssistantContent(context: StreamContext, message: AgentMessage): void { + if (message.type !== 'text' || !message.content) { + return; + } + + context.hasYieldedText = true; + if (message.streamMode === 'replace') { + context.assistantContent = message.content; + return; + } + + context.assistantContent += message.content; + } + async *execute(prompt: string, options?: ExecuteOptions): AsyncGenerator { // Check if CLI is available if (!(await this.isAvailable())) { @@ -247,6 +269,7 @@ ${prompt}`; args, workingDirectory: options?.workingDirectory ?? process.cwd(), signal: options?.signal, + env: this.getSpawnEnv(options), }); this.log.debug('Process spawned', { pid: proc.pid, command: fullCommand }); @@ -316,10 +339,7 @@ ${prompt}`; contentLength: msg.content?.length ?? 0, contentPreview: msg.content?.slice(0, 100), }); - if (msg.type === 'text' && msg.content) { - context.hasYieldedText = true; - context.assistantContent += msg.content; - } + this.updateAssistantContent(context, msg); yield msg; } } diff --git a/src/lib/executors/claude-executor.ts b/src/lib/executors/claude-executor.ts index a46292c..9ecccbd 100644 --- a/src/lib/executors/claude-executor.ts +++ b/src/lib/executors/claude-executor.ts @@ -4,6 +4,7 @@ */ import type { AgentMessage, ExecuteOptions } from '../executor'; import { BaseExecutor, type StreamContext, type StreamParserType } from './base-executor'; +import { extractCumulativeDelta } from './stream-delta'; import type { CLIContentBlock, ClaudeCLIMessage } from './types'; export class ClaudeExecutor extends BaseExecutor { @@ -32,10 +33,10 @@ export class ClaudeExecutor extends BaseExecutor { protected buildArgs(prompt: string, options?: ExecuteOptions): string[] { const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose']; - // Permission handling: always use acceptEdits mode - // This allows file operations while maintaining safety boundaries - // Both one-shot (command-line) and chat modes use the same permission level - args.push('--permission-mode', 'acceptEdits'); + // Permission handling: fully auto-approve tool execution. + // Required for non-interactive /install build steps (xcodebuild, xcrun, etc.). + args.push('--allow-dangerously-skip-permissions'); + args.push('--permission-mode', 'bypassPermissions'); // Session persistence: disable for one-shot, enable for chat if (options?.oneShot) { @@ -106,9 +107,7 @@ export class ClaudeExecutor extends BaseExecutor { } private extractTextDelta(textContent: string, msg: ClaudeCLIMessage): AgentMessage | null { - const delta = textContent.startsWith(this.lastTextContent) - ? textContent.slice(this.lastTextContent.length) - : textContent; + const delta = extractCumulativeDelta(this.lastTextContent, textContent); this.lastTextContent = textContent; @@ -116,6 +115,7 @@ export class ClaudeExecutor extends BaseExecutor { return { type: 'text', content: delta, + streamMode: 'append', metadata: msg as unknown as Record, }; } diff --git a/src/lib/executors/cli-process-manager.ts b/src/lib/executors/cli-process-manager.ts index c9432ca..2d7cb2c 100644 --- a/src/lib/executors/cli-process-manager.ts +++ b/src/lib/executors/cli-process-manager.ts @@ -12,6 +12,7 @@ export interface CLIProcessOptions { args: string[]; workingDirectory?: string; signal?: AbortSignal; + env?: NodeJS.ProcessEnv; } export interface CLIProcessResult { @@ -77,12 +78,13 @@ export async function* parseTextLineStream(stream: Readable): AsyncGenerator, }; } diff --git a/src/lib/executors/copilot-executor.ts b/src/lib/executors/copilot-executor.ts index f99bb8b..c9a058c 100644 --- a/src/lib/executors/copilot-executor.ts +++ b/src/lib/executors/copilot-executor.ts @@ -89,12 +89,13 @@ export class CopilotExecutor extends BaseExecutorClass { contentLine = line.replace(/\s*\([^)]+\)[.]?$/, '').trim(); } - // Add line to assistant content (with newline if not first line) - const lineWithNewline = context.assistantContent ? `\n${contentLine}` : contentLine; + // Add line to assistant content (with newline if not first emitted line) + const lineWithNewline = context.hasYieldedText ? `\n${contentLine}` : contentLine; return { type: 'text', content: lineWithNewline, + streamMode: 'append', metadata: progressAction ? { isProgress: true, diff --git a/src/lib/executors/cursor-executor.ts b/src/lib/executors/cursor-executor.ts index 2144f50..33c8a71 100644 --- a/src/lib/executors/cursor-executor.ts +++ b/src/lib/executors/cursor-executor.ts @@ -4,9 +4,12 @@ */ import type { AgentMessage, ExecuteOptions } from '../executor'; import { BaseExecutor, type StreamContext, type StreamParserType } from './base-executor'; +import { extractCumulativeDelta } from './stream-delta'; import type { CLIContentBlock, CursorCLIMessage } from './types'; export class CursorExecutor extends BaseExecutor { + private lastTextContent = ''; + constructor() { super({ name: 'cursor', @@ -16,6 +19,11 @@ export class CursorExecutor extends BaseExecutor { }); } + override async *execute(prompt: string, options?: ExecuteOptions): AsyncGenerator { + this.lastTextContent = ''; + yield* super.execute(prompt, options); + } + protected getStreamParserType(): StreamParserType { return 'jsonl'; } @@ -54,6 +62,23 @@ export class CursorExecutor extends BaseExecutor { this.sessionId = null; } + private extractTextDelta(textContent: string, msg: CursorCLIMessage): AgentMessage | null { + const delta = extractCumulativeDelta(this.lastTextContent, textContent); + + this.lastTextContent = textContent; + + if (!delta) { + return null; + } + + return { + type: 'text', + content: delta, + streamMode: 'append', + metadata: msg as unknown as Record, + }; + } + private mapAssistantMessage(msg: CursorCLIMessage): AgentMessage | AgentMessage[] | null { if (!msg.message?.content) return null; @@ -69,11 +94,10 @@ export class CursorExecutor extends BaseExecutor { .join(''); if (textContent) { - results.push({ - type: 'text', - content: textContent, - metadata: msg as unknown as Record, - }); + const textMessage = this.extractTextDelta(textContent, msg); + if (textMessage) { + results.push(textMessage); + } } // 도구 호출 추출 diff --git a/src/lib/executors/gemini-executor.ts b/src/lib/executors/gemini-executor.ts index 0d0402d..2f729a0 100644 --- a/src/lib/executors/gemini-executor.ts +++ b/src/lib/executors/gemini-executor.ts @@ -4,6 +4,7 @@ */ import type { AgentMessage, ExecuteOptions } from '../executor'; import { BaseExecutor, type StreamContext, type StreamParserType } from './base-executor'; +import { extractCumulativeDelta } from './stream-delta'; import type { GeminiCLIErrorEvent, GeminiCLIInitEvent, @@ -15,6 +16,8 @@ import type { } from './types'; export class GeminiExecutor extends BaseExecutor { + private lastTextContent = ''; + constructor() { super({ name: 'gemini', @@ -64,6 +67,11 @@ export class GeminiExecutor extends BaseExecutor { this.sessionId = null; } + override async *execute(prompt: string, options?: ExecuteOptions): AsyncGenerator { + this.lastTextContent = ''; + yield* super.execute(prompt, options); + } + protected processStreamData( data: unknown, _context: StreamContext, @@ -76,9 +84,21 @@ export class GeminiExecutor extends BaseExecutor { const messageEvent = msg as GeminiCLIMessageEvent; // Only process assistant messages, not user messages if (messageEvent.role === 'assistant' && messageEvent.content) { + const nextTextContent = + messageEvent.delta === true + ? `${this.lastTextContent}${messageEvent.content}` + : messageEvent.content; + const delta = extractCumulativeDelta(this.lastTextContent, nextTextContent); + this.lastTextContent = nextTextContent; + + if (!delta) { + return null; + } + return { type: 'text', - content: messageEvent.content, + content: delta, + streamMode: 'append', metadata: { delta: messageEvent.delta, timestamp: messageEvent.timestamp, diff --git a/src/lib/executors/opencode-executor.ts b/src/lib/executors/opencode-executor.ts index d303fa6..06d13e2 100644 --- a/src/lib/executors/opencode-executor.ts +++ b/src/lib/executors/opencode-executor.ts @@ -28,6 +28,18 @@ export class OpenCodeExecutor extends BaseExecutor { return 'jsonl'; } + protected override getSpawnEnv(_options?: ExecuteOptions): NodeJS.ProcessEnv | undefined { + if (process.env.OPENCODE_PERMISSION) { + return undefined; + } + + // Default to allow-all tool permission to avoid interactive approval prompts. + return { + ...process.env, + OPENCODE_PERMISSION: '{"*":"allow"}', + }; + } + protected buildArgs(prompt: string, options?: ExecuteOptions): string[] { const args = ['run', '--format', 'json']; @@ -86,6 +98,7 @@ export class OpenCodeExecutor extends BaseExecutor { return { type: 'text', content: messageEvent.content, + streamMode: 'append', metadata: { role: messageEvent.role, timestamp: messageEvent.timestamp, @@ -101,6 +114,7 @@ export class OpenCodeExecutor extends BaseExecutor { return { type: 'text', content: text, + streamMode: 'append', metadata: { timestamp: textEvent.timestamp, }, diff --git a/src/lib/executors/stream-delta.ts b/src/lib/executors/stream-delta.ts new file mode 100644 index 0000000..d2e9958 --- /dev/null +++ b/src/lib/executors/stream-delta.ts @@ -0,0 +1,38 @@ +/** + * Extract append-only delta from streamed text snapshots. + * + * This keeps a cumulative transcript visible in terminal UI even when + * upstream agents emit full snapshot rewrites. + */ +function findSuffixPrefixOverlap(previous: string, next: string): number { + const maxLength = Math.min(previous.length, next.length); + for (let length = maxLength; length > 0; length--) { + if (previous.endsWith(next.slice(0, length))) { + return length; + } + } + return 0; +} + +export function extractCumulativeDelta(previous: string, next: string): string { + if (!previous) { + return next; + } + + if (next.startsWith(previous)) { + return next.slice(previous.length); + } + + const overlap = findSuffixPrefixOverlap(previous, next); + if (overlap > 0) { + return next.slice(overlap); + } + + // Snapshot rewound to an older segment that is already shown. + if (next.length <= previous.length && previous.includes(next)) { + return ''; + } + + // Fully rewritten snapshot: append as a new log block. + return `\n${next}`; +} diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 5fd37a6..eb022c6 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -47,7 +47,13 @@ const LOCAL_SKILLS: SkillInfo[] = [ { type: 'install', name: 'SDK Installation', - description: 'Autonomous SDK installation with automatic file modifications', + description: 'Autonomous SDK integration with automatic file modifications', + isLocal: true, + }, + { + type: 'project-build', + name: 'Project Build', + description: 'Autonomous project build with automatic diagnostics and fixes', isLocal: true, }, { @@ -231,6 +237,30 @@ function readLocalSkillPrompt(skillName: string): string { } } +function formatSetupStepStatus(required: boolean, configured: boolean): string { + if (!required) { + return '- not required'; + } + return configured ? '✓ verified' : '✗ missing'; +} + +function appendOptionalSection( + lines: string[], + title: string, + rows: string[], + enabled: boolean = true, +): void { + if (!enabled) { + return; + } + + lines.push(`### ${title}`); + for (const row of rows) { + lines.push(`- ${row}`); + } + lines.push(''); +} + /** * Build pre-configured setup section from preparation context. */ @@ -240,65 +270,87 @@ function buildPreparationSection(context: PreparationContext): string { lines.push(`Type: ${formatProjectType(context.projectType)}`); lines.push(''); - // Clix project info - lines.push('### Clix Project'); - lines.push(`- Project ID: ${context.config.project.id}`); - if (context.config.project.publicKey) { - lines.push(`- Public Key: ${context.config.project.publicKey}`); - } - lines.push(''); - - // Firebase status - if (context.firebase.needed) { - lines.push('### Firebase'); - lines.push(`- Project ID: ${context.firebase.projectId || 'not configured'}`); - lines.push( - `- Android (google-services.json): ${context.firebase.androidConfigured ? '✓ configured' : '✗ missing'}`, - ); - lines.push( - `- iOS (GoogleService-Info.plist): ${context.firebase.iosConfigured ? '✓ configured' : '✗ missing'}`, - ); - lines.push( - `- Sender Config (App Push): ${context.firebase.senderConfigConfigured ? '✓ configured' : '✗ missing'}`, - ); - lines.push(''); - } + const iosTarget = + context.projectType.target === 'ios' || context.projectType.target === 'ios-android'; + const androidTarget = + context.projectType.target === 'android' || context.projectType.target === 'ios-android'; + const firebaseFilesConfigured = + !context.firebase.needed || + ((!iosTarget || context.firebase.iosConfigured) && + (!androidTarget || context.firebase.androidConfigured)); + appendOptionalSection(lines, 'Install Step Verification', [ + 'Project linked: ✓ verified', + `Firebase Configuration Files: ${formatSetupStepStatus( + context.firebase.needed, + firebaseFilesConfigured, + )}`, + `APNS Key for Firebase: ${formatSetupStepStatus( + context.apns.needed, + context.apns.registeredWithFirebase, + )}`, + `Firebase Service Account: ${formatSetupStepStatus( + context.firebase.needed, + context.firebase.senderConfigConfigured, + )}`, + `iOS Entitlements: ${formatSetupStepStatus(context.ios.needed, context.ios.entitlementsConfigured)}`, + `Notification Service Extension: ${formatSetupStepStatus( + context.ios.needed, + context.ios.nseConfigured, + )}`, + ]); + + appendOptionalSection(lines, 'Clix Project', [ + `Project ID: ${context.config.project.id}`, + ...(context.config.project.publicKey + ? [`Public Key: ${context.config.project.publicKey}`] + : []), + ]); + + appendOptionalSection( + lines, + 'Firebase', + [ + `Project ID: ${context.firebase.projectId || 'not configured'}`, + `Android (google-services.json): ${context.firebase.androidConfigured ? '✓ configured' : '✗ missing'}`, + `iOS (GoogleService-Info.plist): ${context.firebase.iosConfigured ? '✓ configured' : '✗ missing'}`, + `Sender Config (App Push): ${context.firebase.senderConfigConfigured ? '✓ configured' : '✗ missing'}`, + ], + context.firebase.needed, + ); - if (context.apns.needed) { - lines.push('### APNS'); - lines.push(`- Key ID: ${context.apns.keyId || 'not configured'}`); - lines.push(`- Team ID: ${context.apns.teamId || 'not configured'}`); - lines.push( - `- Registered with Firebase: ${context.apns.registeredWithFirebase ? '✓ configured' : '✗ missing'}`, - ); - lines.push(''); - } + appendOptionalSection( + lines, + 'APNS', + [ + `Key ID: ${context.apns.keyId || 'not configured'}`, + `Team ID: ${context.apns.teamId || 'not configured'}`, + `Registered with Firebase: ${context.apns.registeredWithFirebase ? '✓ configured' : '✗ missing'}`, + ], + context.apns.needed, + ); - // iOS status - if (context.ios.needed) { - lines.push('### iOS'); - lines.push(`- Bundle ID: ${context.ios.bundleId || 'not detected'}`); - lines.push(`- Team ID: ${context.ios.teamId || 'not detected'}`); - lines.push(`- App Group: ${context.ios.appGroupId || 'not configured'}`); - lines.push( - `- Entitlements: ${context.ios.entitlementsConfigured ? '✓ configured' : '✗ not configured'}`, - ); - lines.push( - `- NSE (Notification Service Extension): ${context.ios.nseConfigured ? '✓ configured' : '✗ not configured'}`, - ); - lines.push(''); - } + appendOptionalSection( + lines, + 'iOS', + [ + `Bundle ID: ${context.ios.bundleId || 'not detected'}`, + `Team ID: ${context.ios.teamId || 'not detected'}`, + `App Group: ${context.ios.appGroupId || 'not configured'}`, + `Entitlements: ${context.ios.entitlementsConfigured ? '✓ configured' : '✗ not configured'}`, + `NSE (Notification Service Extension): ${context.ios.nseConfigured ? '✓ configured' : '✗ not configured'}`, + ], + context.ios.needed, + ); - // Missing items - if (context.missing.length > 0) { - lines.push('### Missing Setup (handle during installation)'); - for (const item of context.missing) { - lines.push(`- ${item}`); - } - lines.push(''); - } + appendOptionalSection( + lines, + 'Missing Setup (handle before build)', + context.missing, + context.missing.length > 0, + ); - lines.push('Use these pre-configured values when integrating the SDK.'); + lines.push('Treat the above as already executed/validated by /install.'); + lines.push('Use these pre-configured values when running build and troubleshooting failures.'); lines.push(''); return lines.join('\n'); @@ -306,15 +358,32 @@ function buildPreparationSection(context: PreparationContext): string { /** * Get prompt for the install skill. - * Uses the autonomous installation prompt optimized for one-shot execution. + * Uses the SDK integration workflow prompt. * Prompt is loaded from src/lib/skills/install/SKILL.md */ function getInstallPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); - const platform = options?.platform ?? 'auto-detect'; const context = options?.preparationContext; - - let prompt = `Project path: ${projectPath}\nTarget platform: ${platform}\n\n`; + const inferredPlatform = + context?.projectType.framework === 'flutter' + ? 'flutter' + : context?.projectType.framework === 'react-native' || + context?.projectType.framework === 'expo' + ? 'react-native' + : context?.projectType.framework === 'native' + ? context.projectType.target === 'ios' + ? 'ios' + : context.projectType.target === 'android' + ? 'android' + : undefined + : undefined; + const platform = options?.platform ?? inferredPlatform ?? 'auto-detect'; + + let prompt = `Project path: ${projectPath}\nTarget platform: ${platform}\n`; + if (context) { + prompt += `Detected project type: ${formatProjectType(context.projectType)}\n`; + } + prompt += '\n'; // Add preparation context if available if (context) { @@ -337,14 +406,63 @@ You are in autonomous one-shot execution mode: `; } - - // Load the autonomous installation prompt from external file const installPrompt = readLocalSkillPrompt('install'); prompt += installPrompt; return prompt; } +/** + * Get prompt for the project-build skill. + * Prompt is loaded from src/lib/skills/project-build/SKILL.md + */ +function getProjectBuildPrompt(options?: SkillOptions): string { + const projectPath = options?.projectPath ?? process.cwd(); + const context = options?.preparationContext; + const inferredPlatform = + context?.projectType.framework === 'flutter' + ? 'flutter' + : context?.projectType.framework === 'react-native' || + context?.projectType.framework === 'expo' + ? 'react-native' + : context?.projectType.framework === 'native' + ? context.projectType.target === 'ios' + ? 'ios' + : context.projectType.target === 'android' + ? 'android' + : undefined + : undefined; + const platform = options?.platform ?? inferredPlatform ?? 'auto-detect'; + + let prompt = `Project path: ${projectPath}\nTarget platform: ${platform}\n`; + if (context) { + prompt += `Detected project type: ${formatProjectType(context.projectType)}\n`; + } + prompt += '\n'; + + if (context) { + prompt += buildPreparationSection(context); + prompt += '\n'; + } + + if (options?.oneShot) { + prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; + prompt += `## EXECUTION MODE: AUTONOMOUS + +You are in autonomous one-shot execution mode: +- ALL file operations are pre-approved +- Use Write/Edit/Bash tools immediately without asking +- Complete all integration steps automatically +- Report what was done, not what should be done + +`; + } + + const projectBuildPrompt = readLocalSkillPrompt('project-build'); + prompt += projectBuildPrompt; + return prompt; +} + export async function getSkillPrompt( skillType: SkillType, options?: SkillOptions, @@ -389,8 +507,9 @@ Target platform: ${platform} async function getLocalSkillPrompt(skillType: SkillType, options?: SkillOptions): Promise { switch (skillType) { case 'install': - // install uses autonomous installation prompt return getInstallPrompt(options); + case 'project-build': + return getProjectBuildPrompt(options); case 'doctor': return getDoctorPrompt(options); case 'debug': diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index 11c4a00..2a8b397 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -1,206 +1,87 @@ -# Clix SDK Autonomous Installation +# Clix SDK Autonomous Integration -You are an autonomous AI agent that installs and integrates the Clix mobile push notification SDK. +You are an autonomous AI agent that integrates the Clix mobile SDK into an existing project. + +## Scope + +This prompt is only for **SDK code integration**. + +Do not perform or re-describe setup steps that are already handled by `/install` preparation tasks: +- Firebase configuration files +- APNS key registration in Firebase +- Firebase service account sender config +- iOS entitlements setup +- Notification Service Extension setup ## Core Directive -**MODIFY FILES DIRECTLY** - You have full permission to create, edit, and delete files. Use Write and Edit tools immediately without asking for permission or confirmation. +**MODIFY FILES DIRECTLY** - Create/edit files immediately without asking for confirmation. -## Supported Platforms & Documentation +## Supported Platforms & Docs - iOS: https://docs.clix.so/sdk-quickstart-ios - Android: https://docs.clix.so/sdk-quickstart-android - React Native: https://docs.clix.so/sdk-quickstart-react-native - Flutter: https://docs.clix.so/sdk-quickstart-flutter -## Platform Detection Priority +## Required Work + +### 1. Detect project platform and dependency manager + +Detect platform from project files, then apply the matching integration path: +- iOS native (SPM / CocoaPods) +- Android native (Gradle) +- React Native / Expo (npm, yarn, pnpm, or bun) +- Flutter (pubspec) + +### 2. Add Clix SDK dependency + +Apply dependency changes directly in project files. + +- iOS SPM: add Clix package and product dependency +- iOS CocoaPods: add `pod 'Clix'` and run pod install +- Android: add Clix dependency in Gradle +- React Native: add `@clix-so/react-native-sdk` +- Flutter: add `clix_flutter_sdk` + +### 3. Integrate SDK initialization in app code + +Add initialization code in the real app entry path for the detected platform. +Use placeholders when credentials are missing: +- `YOUR_CLIX_PROJECT_ID` +- `YOUR_CLIX_PUBLIC_API_KEY` + +### 4. Run post-change package/install commands + +Run only commands needed for dependency resolution/build graph update, for example: +- `npm install` / `yarn install` / `pnpm install` / `bun install` +- `pod install` (if Podfile changed) +- `flutter pub get` -1. Check for cross-platform frameworks first: - - `package.json` with React Native/Expo dependencies - - `pubspec.yaml` for Flutter -2. Then check native platforms: - - **iOS (in order of priority):** - 1. `Package.swift` with iOS platform target → **Pure SPM iOS project** - - Look for patterns: `.iOS`, `.iOS(.v13)`, `.iOS(.v14)`, `platforms: [.iOS]`, `platforms: [.iOS(.v13)]` - 2. `Podfile` exists → **CocoaPods project** - 3. `*.xcodeproj/project.pbxproj` containing `XCRemoteSwiftPackageReference` → **Xcode with SPM** - 4. `*.xcodeproj` or `*.xcworkspace` only → **Suggest SPM** (modern, recommended) - - Note: `Package.swift` without iOS platform is likely a server-side Swift or CLI project, not iOS - - `build.gradle` or `AndroidManifest.xml` for Android - -## Installation Steps - -### 1. Detect Platform -Analyze project files to identify the platform. - -### 2. Install SDK Package - -**React Native:** -- Add `@clix-so/react-native-sdk` to package.json -- Run npm/yarn install - -**iOS (Dependency Manager Detection):** - -First, detect the dependency manager being used: - -1. **Pure SPM iOS Project** (has `Package.swift` with iOS platform target): - - First verify Package.swift contains iOS platform (`.iOS`, `.iOS(.v13)`, `platforms: [.iOS]`, etc.) - - If no iOS platform found, this is likely a server-side Swift project - skip iOS installation - - Read Package.swift and add to dependencies array: - ```swift - .package(url: "https://github.com/clix-so/clix-ios-sdk.git", from: "1.0.0") - ``` - - Add to target dependencies: - ```swift - .product(name: "Clix", package: "clix-ios-sdk") - ``` - - Run `swift package resolve` - -2. **CocoaPods Project** (has `Podfile`): - - Add to Podfile: - ```ruby - pod 'Clix', :git => 'https://github.com/clix-so/clix-ios-sdk.git' - ``` - - Run: `cd ios && pod install` - -3. **Xcode Project with SPM** (has `project.pbxproj` with `XCRemoteSwiftPackageReference`): - - Inform user to add via Xcode: File > Add Package Dependencies - - URL: `https://github.com/clix-so/clix-ios-sdk` - - Note: Direct .pbxproj modification is complex; prefer Xcode UI - -4. **Bare Xcode Project** (only `*.xcodeproj` or `*.xcworkspace`): - - Recommend SPM: Guide user to add via Xcode (File > Add Package Dependencies) - - Alternative: Create Podfile and use CocoaPods - -**Android:** -- Add to build.gradle - -**Flutter:** -- Add to pubspec.yaml -- Run flutter pub get - -### 3. Create/Modify Files Directly - -**React Native:** -- Create initialization module or add to existing entry file -- Update constants file with CLIX_PROJECT_ID and CLIX_PUBLIC_API_KEY exports -- Add initialization call in app entry point (app/_layout.tsx or index.js) -- Add configuration to environment files with placeholders - -**iOS:** -- Modify AppDelegate to initialize SDK -- Add Info.plist entries -- Note: Xcode capabilities (Push Notifications, Background Modes) require manual IDE steps - -**Android:** -- Modify MainActivity or Application class -- Update AndroidManifest.xml with permissions -- Verify Firebase configuration (see step 6) - -**Flutter:** -- Modify main.dart to initialize SDK -- Update platform-specific files as needed -- Verify Firebase configuration (see step 6) - -### 4. Use Placeholders for Secrets - -Use `YOUR_CLIX_PROJECT_ID` and `YOUR_CLIX_PUBLIC_API_KEY` as placeholders. Inform user to replace with actual credentials from https://console.clix.so/ - -### 5. Run Post-Installation Commands - -Execute necessary commands: -- `npm install` or `yarn install` after package.json changes -- `cd ios && pod install` for iOS dependencies -- `flutter pub get` for Flutter - -### 6. Verify Firebase Configuration - -For push notifications to work, Firebase must be properly configured: - -**Android (google-services.json):** -- Expected locations: - - Standard Android: `app/google-services.json` - - React Native/Flutter: `android/app/google-services.json` -- Download from Firebase Console > Project Settings > Your apps > Android app -- Verify package name matches your AndroidManifest.xml - -**iOS (GoogleService-Info.plist):** -- Expected locations: - - React Native: `ios/GoogleService-Info.plist` - - Flutter: `ios/Runner/GoogleService-Info.plist` - - Native iOS: `/GoogleService-Info.plist` -- Download from Firebase Console > Project Settings > Your apps > iOS app -- Verify bundle ID matches your Xcode project - -**Validation:** -- Check if files exist in correct locations -- Verify JSON/plist structure is valid -- Confirm project IDs match between platforms (for cross-platform apps) - -Use `/install` in interactive mode to run required Firebase setup tasks. - -## Automation Rules - -✅ **DO:** -- Use Write tool to create new files immediately -- Use Edit tool to modify existing files immediately -- Use Bash tool to run installation commands -- Proceed autonomously through all steps -- Report what was done after completion - -❌ **DO NOT:** -- Ask for permission or confirmation -- Say "you should" or "please add" - just do it -- Provide manual steps for code changes - make the changes -- Wait for user input (except for IDE-only tasks) - -## IDE-Only Manual Steps - -Only these require user action: -- Xcode: Adding capabilities, configuring entitlements -- Android Studio: Firebase setup UI, capability configuration -- Building and running the project - -For these, provide brief instructions but don't wait for confirmation. +### 5. Verify integration artifacts -### iOS Notification Service Extension (Recommended) - -For rich push notifications (images, buttons), create a Notification Service Extension: +Verify that: +- dependency declarations were added correctly +- initialization code is present in app startup path +- imports and references resolve +- no duplicate initialization exists -1. In Xcode: File > New > Target > Notification Service Extension -2. Add Clix SDK to the extension target: - - **CocoaPods**: Add `target 'YourExtension' do pod 'Clix' end` to Podfile, then `pod install` - - **SPM**: Add Clix package to extension target in Xcode (General > Frameworks) -3. Implement NotificationService.swift: - ```swift - import UserNotifications - import Clix - - class NotificationService: ClixNotificationServiceExtension { - override init() { - super.init() - register(projectId: "YOUR_PROJECT_ID") - } - - override func didReceive(_ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - super.didReceive(request, withContentHandler: contentHandler) - } +## Rules - override func serviceExtensionTimeWillExpire() { - super.serviceExtensionTimeWillExpire() - } - } - ``` -4. Add App Groups capability to both main app and extension (same group ID: `group.clix.{BUNDLE_ID}`) -5. For Xcode 15+: Set `ENABLE_USER_SCRIPT_SANDBOXING` to "No" in extension's Build Settings +✅ Do: +- apply concrete file changes +- keep edits minimal and project-specific +- preserve existing code style -For detailed setup, run `clix install` or `/install` in interactive mode. +❌ Do not: +- redo Firebase/APNS/Entitlements/NSE tasks +- ask the user to manually apply code edits +- add speculative abstractions ## Output Format -After completion, report: -✓ Files created/modified (with paths) -✓ Commands executed -✓ Placeholders that need replacement -✓ Any IDE-only steps required +At completion, report: +- modified files +- commands executed +- placeholders that still need real values +- any hard blockers (if any) diff --git a/src/lib/skills/project-build/SKILL.md b/src/lib/skills/project-build/SKILL.md new file mode 100644 index 0000000..079049e --- /dev/null +++ b/src/lib/skills/project-build/SKILL.md @@ -0,0 +1,167 @@ +--- +name: project-build +description: Build the project using project type and install verification context passed from /install. Validate the provided project type with quick file checks, run the build, and on failure automatically apply fixes and retry without asking for user approval. +--- + +# Project Build + +Use the context already collected by `/install` to run a reliable build and handle failures autonomously. + +## Inputs from `/install` (already provided) + +Assume these inputs are available in the prompt and use them as the primary source of truth: + +- Project path +- Target platform +- Detected project type (framework + target) +- Install-step verification summary (what was completed and validated) +- Firebase/APNS/iOS setup status + +Do not rerun previous setup tasks unless file evidence conflicts. + +## Step 1: Use Provided Project Type, Then Validate Quickly + +Start from the provided project type/target platform. Then run a quick validation scan in parallel using indicator files: + +| Indicator Files | Project Type | +|---|---| +| `Podfile` + `*.xcworkspace` | iOS native (CocoaPods) | +| `*.xcodeproj` + `Package.swift` OR `*.xcodeproj` with no `Podfile` | iOS native (SPM) | +| `build.gradle` or `build.gradle.kts` (at root or `app/`) | Android | +| `package.json` with `"react-native"` dependency + `android/` and `ios/` dirs | React Native | +| `package.json` with `"expo"` dependency OR `app.json` with `"expo"` key | Expo | +| `pubspec.yaml` | Flutter | + +If the quick validation conflicts with the provided project type, use file-evidence as source of truth, mention the mismatch in output, and continue. + +## Step 2: Determine Build Command + +### iOS simulator discovery (xcodebuild only) + +When using `xcodebuild` (iOS native CocoaPods/SPM), discover available simulators before constructing the command: + +```bash +xcrun simctl list devices available -j +``` + +Choose simulator priority: +1. Latest iOS runtime +2. Highest iPhone model number in that runtime (prefer base iPhone model over Pro/Plus/Max) +3. Fallback to any iPad if no iPhone exists + +Use destination: +``` +-destination 'platform=iOS Simulator,name=' +``` + +If simulator discovery fails, inform the user and suggest installing simulators via Xcode. + +### Default build commands by project type + +**iOS native (CocoaPods)**: +``` +pod install --project-directory= +xcodebuild -workspace .xcworkspace -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build +``` +- Use `xcodebuild -workspace .xcworkspace -list` to list schemes. + +**iOS native (SPM)**: +``` +xcodebuild -project .xcodeproj -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build +``` +- Use `xcodebuild -project .xcodeproj -list` to list schemes. + +**Android**: +``` +./gradlew assembleDebug +``` + +**React Native**: +``` +npx react-native run-ios +# or +npx react-native run-android +``` + +**Expo**: +``` +npx expo run:ios +# or +npx expo run:android +``` + +**Flutter**: +``` +flutter build ios --debug --no-codesign +# or +flutter build apk --debug +``` + +### Override detection + +Also check for custom build commands: +- `CLAUDE.md` +- `Makefile` (`build` target) +- `package.json` scripts (`build`, `ios`, `android`) +- `Justfile` / `Taskfile.yml` +- `fastlane/Fastfile` + +Prefer custom commands when valid. + +## Step 3: Finalize Build Plan and Execute + +Before running build, summarize internally: +1. Project type from `/install` +2. Install-step verification summary (completed + missing) +3. Build command that will run +4. Simulator/device info (for iOS `xcodebuild`) + +Do not ask for approval. Proceed immediately with the best command. + +## Step 4: Execute Build + +Run the confirmed command with Bash (timeout up to `600000ms`). + +After build: +- On success: provide concise success summary and key output artifacts. +- On failure: provide error summary (first root-cause lines + failing command). + +## Step 5: On Build Failure, Auto-Fix and Retry + +After a failed build, do not stop for approval. + +- Apply minimal targeted fixes automatically. +- Retry build after each fix. +- Repeat until build succeeds or a hard blocker remains (missing secrets, account permissions, unavailable external service). +- For hard blockers, stop and clearly report blocker + exact action required from user. + +## Step 6: Build Failure Checklist to Show User + +Provide a quick checklist when build fails (including intermediate retries): + +**Common** +- Dependencies installed and lockfiles are in sync (`bun install`/`npm install`/`yarn`/`pnpm install`) +- Clean stale caches/build artifacts +- Correct CLI/toolchain versions (Node, Java, Flutter, Xcode CLT) + +**iOS** +- Correct workspace/project and scheme +- Simulator exists and is bootable +- CocoaPods installed (`pod install`) when using pods +- Signing/capabilities/provisioning consistency +- Firebase plist, entitlements, and NSE files/targets linked correctly + +**Android** +- Gradle sync and compatible JDK/SDK versions +- `google-services.json` path and Google Services plugin setup +- Build variant/task matches project structure + +**React Native / Expo** +- `node_modules` installed +- iOS pods installed (`cd ios && pod install`) for iOS build paths +- Correct run command for target platform + +**Flutter** +- `flutter pub get` completed +- iOS pods/Xcode toolchain healthy for iOS builds +- Target device/emulator availability diff --git a/src/lib/utils/__tests__/stream-text.test.ts b/src/lib/utils/__tests__/stream-text.test.ts new file mode 100644 index 0000000..aee8446 --- /dev/null +++ b/src/lib/utils/__tests__/stream-text.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test'; +import { normalizeStreamText } from '../stream-text'; + +describe('normalizeStreamText', () => { + test('normalizes CRLF and CR to LF', () => { + const input = 'line1\r\nline2\rline3\nline4'; + expect(normalizeStreamText(input)).toBe('line1\nline2\nline3\nline4'); + }); + + test('removes control characters except tab and newline', () => { + const input = `a\u0000b\u0007c\tok\nline\u001Fd`; + expect(normalizeStreamText(input)).toBe('abc\tok\nlined'); + }); +}); diff --git a/src/lib/utils/stream-text.ts b/src/lib/utils/stream-text.ts new file mode 100644 index 0000000..29ee9ae --- /dev/null +++ b/src/lib/utils/stream-text.ts @@ -0,0 +1,17 @@ +/** + * Normalize streamed text chunks before rendering in terminal UI. + * This avoids layout corruption from mixed newline styles and control characters. + */ +export function normalizeStreamText(input: string): string { + let normalized = input.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + + // Strip ASCII control characters except TAB(0x09) and LF(0x0A). + normalized = [...normalized] + .filter((char) => { + const code = char.charCodeAt(0); + return code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f); + }) + .join(''); + + return normalized; +} diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index 906cf5a..a948f4f 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -90,6 +90,7 @@ const ChatAppInner: React.FC navigateHistory, transferSession, addSystemMessage, + executeSkillWithResult, } = chatActions; // Initialize agent after session restore (if any) @@ -117,12 +118,19 @@ const ChatAppInner: React.FC [onExit, exit], ); - // Execute install skill with preparation context - const executeInstallWithContext = useCallback( + // Execute install build step with preparation context + const runInstallProjectBuild = useCallback( (context: Parameters[1]) => { - chatActions.executeSkill('install', context); + return executeSkillWithResult('project-build', context); }, - [chatActions], + [executeSkillWithResult], + ); + + const runInstallSkill = useCallback( + (context: Parameters[1]) => { + return executeSkillWithResult('install', context); + }, + [executeSkillWithResult], ); // Overlay management @@ -133,8 +141,10 @@ const ChatAppInner: React.FC transferSession, switchAgent: chatActions.switchAgent, resumeSession: chatActions.resumeSession, + cancelRequest: chatActions.cancelRequest, executeDebugSession: chatActions.executeDebugSession, - executeInstallWithContext, + runInstallProjectBuild, + runInstallSkill, }); // Handle direct transfer command with agent name @@ -241,6 +251,9 @@ const ChatAppInner: React.FC {overlays.activeOverlay === 'install-preparation' && ( diff --git a/src/ui/chat/components/MessageList.tsx b/src/ui/chat/components/MessageList.tsx index 49cfb2d..14b06e5 100644 --- a/src/ui/chat/components/MessageList.tsx +++ b/src/ui/chat/components/MessageList.tsx @@ -1,10 +1,6 @@ -import { Box, Text } from 'ink'; import type React from 'react'; -import { BashOutputDisplay } from '@/ui/components/BashOutputDisplay'; -import { ToolCallDisplay } from '@/ui/components/ToolCallDisplay'; +import { ChatMessageList } from '@/ui/components/ChatMessageList'; import type { ChatMessage } from '../context/ChatContext'; -import { AgentMessage } from './AgentMessage'; -import { UserMessage } from './UserMessage'; interface MessageListProps { messages: ChatMessage[]; @@ -12,131 +8,6 @@ interface MessageListProps { agentName?: string; } -/** - * Filter messages to show only the current tool call (not accumulated list). - * Completed tool calls are hidden, only the last pending/streaming one is shown. - */ -function filterToolMessages(messages: ChatMessage[]): ChatMessage[] { - const result: ChatMessage[] = []; - let completedToolCount = 0; - - // Find the last pending/streaming tool - let lastPendingTool: ChatMessage | null = null; - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if ( - message.role === 'tool' && - (message.status === 'pending' || message.status === 'streaming') - ) { - lastPendingTool = message; - break; - } - } - - for (const message of messages) { - if (message.role === 'tool') { - if (message.status === 'complete' || message.status === 'error') { - // Hide completed tools, just count them - completedToolCount++; - } else if (lastPendingTool && message.id === lastPendingTool.id) { - // Show only the last pending/streaming tool - result.push(message); - } - // Skip other pending tools - } else { - // For non-tool messages, add a summary if there were completed tools before this message - if (completedToolCount > 0 && message.role === 'agent') { - // Add tool summary before agent message - result.push({ - id: `tool-summary-${message.id}`, - role: 'system', - content: `[Tool] ✓ ${completedToolCount} tool${completedToolCount > 1 ? 's' : ''} executed`, - timestamp: message.timestamp, - }); - completedToolCount = 0; - } - result.push(message); - } - } - - return result; -} - -// Helper function to render a single message -function renderMessage(message: ChatMessage, agentName?: string) { - switch (message.role) { - case 'user': - return ; - - case 'agent': - return ( - - ); - - case 'tool': - return ( - - ); - - case 'bash': - return ( - - ); - - case 'system': - return ( - - {message.content} - - ); - - default: - return null; - } -} - export const MessageList: React.FC = ({ messages, maxHeight, agentName }) => { - if (messages.length === 0) { - return ( - - - Tip: - - Type / to open the command popup; Tab autocompletes slash - commands. - - - - ); - } - - // Filter tool messages to show only current execution - const filteredMessages = filterToolMessages(messages); - - // Render only the last N messages if maxHeight is specified - const displayMessages = maxHeight ? filteredMessages.slice(-maxHeight) : filteredMessages; - - return ( - - {/* Render all messages without Static to maintain proper layout order */} - {displayMessages.map((message) => renderMessage(message, agentName))} - - ); + return ; }; diff --git a/src/ui/chat/hooks/__tests__/useMessageStreaming.test.ts b/src/ui/chat/hooks/__tests__/useMessageStreaming.test.ts new file mode 100644 index 0000000..6187ca3 --- /dev/null +++ b/src/ui/chat/hooks/__tests__/useMessageStreaming.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test'; +import { mergeStreamText } from '../useMessageStreaming'; + +describe('mergeStreamText', () => { + test('appends chunks by default', () => { + expect(mergeStreamText('hello', ' world')).toBe('hello world'); + }); + + test('replaces full content when stream mode is replace', () => { + expect(mergeStreamText('hello world', 'rewritten', 'replace')).toBe('rewritten'); + }); +}); diff --git a/src/ui/chat/hooks/useChatActions.ts b/src/ui/chat/hooks/useChatActions.ts index 27219a8..6f2c04c 100644 --- a/src/ui/chat/hooks/useChatActions.ts +++ b/src/ui/chat/hooks/useChatActions.ts @@ -103,6 +103,7 @@ export function useChatActions(options?: ChatActionsOptions) { cancelRequest: messageSending.cancelRequest, addSystemMessage: messageSending.addSystemMessage, executeSkill: messageSending.executeSkill, + executeSkillWithResult: messageSending.executeSkillWithResult, executeDebugSession: messageSending.executeDebugSession, isStreaming: messageSending.isStreaming, diff --git a/src/ui/chat/hooks/useMessageSending.ts b/src/ui/chat/hooks/useMessageSending.ts index 181415d..b001b6b 100644 --- a/src/ui/chat/hooks/useMessageSending.ts +++ b/src/ui/chat/hooks/useMessageSending.ts @@ -10,6 +10,12 @@ import type { ChatRefs } from './types'; import { useMessageStreaming } from './useMessageStreaming'; import type { SessionPersistenceAPI } from './useSessionPersistence'; +export interface SkillExecutionResult { + success: boolean; + aborted: boolean; + error?: string; +} + /** * Hook for message sending operations. */ @@ -90,17 +96,22 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI ], ); - const executeSkill = useCallback( - async (skillType: SkillType, preparationContext?: PreparationContext) => { + const executeSkillWithResult = useCallback( + async ( + skillType: SkillType, + preparationContext?: PreparationContext, + ): Promise => { if (!executorRef.current) { - addSystemMessage('No agent configured. Please run "clix config" to select an agent.'); - return; + const error = 'No agent configured. Please run "clix config" to select an agent.'; + addSystemMessage(error); + return { success: false, aborted: false, error }; } const skillInfo = getSkillInfo(skillType); if (!skillInfo) { - addSystemMessage(`Unknown skill: ${skillType}`); - return; + const error = `Unknown skill: ${skillType}`; + addSystemMessage(error); + return { success: false, aborted: false, error }; } // Add system message showing skill activation @@ -123,13 +134,47 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI preparationContext, }); - await processStreamingMessages(messageGenerator, agentMessageId, { signal }); + const streamResult = await processStreamingMessages(messageGenerator, agentMessageId, { + signal, + }); + + if (streamResult.aborted || signal.aborted) { + addSystemMessage('Request interrupted.'); + return { + success: false, + aborted: true, + error: 'Request interrupted.', + }; + } + + if (streamResult.errorMessage) { + return { + success: false, + aborted: false, + error: streamResult.errorMessage, + }; + } + + return { + success: true, + aborted: false, + }; } catch (error) { // Handle abort error gracefully if (error instanceof Error && error.name === 'AbortError') { addSystemMessage('Request interrupted.'); + return { + success: false, + aborted: true, + error: 'Request interrupted.', + }; } else { handleStreamingError(error, agentMessageId); + return { + success: false, + aborted: false, + error: error instanceof Error ? error.message : 'Unknown execution error', + }; } } finally { abortControllerRef.current = null; @@ -149,6 +194,13 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI ], ); + const executeSkill = useCallback( + async (skillType: SkillType, preparationContext?: PreparationContext): Promise => { + await executeSkillWithResult(skillType, preparationContext); + }, + [executeSkillWithResult], + ); + const executeDebugSession = useCallback( async (problemDescription: string) => { if (!executorRef.current) { @@ -210,6 +262,7 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI cancelRequest, addSystemMessage, executeSkill, + executeSkillWithResult, executeDebugSession, isStreaming: state.isStreaming, }; diff --git a/src/ui/chat/hooks/useMessageStreaming.ts b/src/ui/chat/hooks/useMessageStreaming.ts index 01d03c8..6c6bc4e 100644 --- a/src/ui/chat/hooks/useMessageStreaming.ts +++ b/src/ui/chat/hooks/useMessageStreaming.ts @@ -2,7 +2,8 @@ * Message streaming hook for handling streamed agent responses. */ import { useCallback } from 'react'; -import type { AgentMessage } from '../../../lib/executor'; +import type { AgentMessage, AgentTextStreamMode } from '../../../lib/executor'; +import { normalizeStreamText } from '../../../lib/utils/stream-text'; import { generateMessageId, useChatContext } from '../context/ChatContext'; import type { ChatRefs } from './types'; @@ -10,6 +11,120 @@ export interface StreamingOptions { signal?: AbortSignal; } +export interface StreamingProcessResult { + aborted: boolean; + errorMessage: string | null; +} + +type ChatDispatch = ReturnType['dispatch']; + +export function mergeStreamText( + current: string, + chunk: string, + streamMode: AgentTextStreamMode = 'append', +): string { + if (streamMode === 'replace') { + return chunk; + } + return `${current}${chunk}`; +} + +function getInterruptedContent(content: string): string { + const interruptedSuffix = content.endsWith('\n') ? '[Interrupted]' : '\n\n[Interrupted]'; + return `${content}${interruptedSuffix}`; +} + +function markPendingToolsComplete(dispatch: ChatDispatch, pendingToolIds: string[]): void { + for (const toolId of pendingToolIds) { + dispatch({ + type: 'UPDATE_MESSAGE', + payload: { + id: toolId, + updates: { status: 'complete' }, + }, + }); + } +} + +function processStreamMessage(params: { + message: AgentMessage; + dispatch: ChatDispatch; + agentMessageId: string; + accumulatedContent: string; + pendingToolIds: string[]; +}): string { + const { message, dispatch, agentMessageId, accumulatedContent, pendingToolIds } = params; + + switch (message.type) { + case 'text': { + const nextContent = mergeStreamText( + accumulatedContent, + normalizeStreamText(message.content), + message.streamMode, + ); + dispatch({ + type: 'UPDATE_MESSAGE', + payload: { + id: agentMessageId, + updates: { content: nextContent, status: 'streaming' }, + }, + }); + return nextContent; + } + + case 'tool_call': { + const toolMessageId = generateMessageId(); + pendingToolIds.push(toolMessageId); + dispatch({ + type: 'ADD_MESSAGE', + payload: { + id: toolMessageId, + role: 'tool', + content: message.content, + toolName: (message.metadata?.toolName as string) ?? 'Tool', + timestamp: new Date(), + status: 'pending', + }, + }); + return accumulatedContent; + } + + case 'tool_result': { + const toolId = pendingToolIds.shift(); + if (toolId) { + const normalizedResult = message.content.replace(/\s+/g, ' ').trim(); + const truncatedResult = + normalizedResult.length > 400 ? `${normalizedResult.slice(0, 397)}...` : normalizedResult; + const updates: { status: 'complete'; content?: string } = { status: 'complete' }; + if (truncatedResult) { + updates.content = truncatedResult; + } + dispatch({ + type: 'UPDATE_MESSAGE', + payload: { + id: toolId, + updates, + }, + }); + } + return accumulatedContent; + } + + case 'error': + dispatch({ + type: 'UPDATE_MESSAGE', + payload: { + id: agentMessageId, + updates: { content: message.content, status: 'error' }, + }, + }); + return accumulatedContent; + + case 'complete': + return accumulatedContent; + } +} + /** * Extract error message from various error types. * Uses duck typing first to handle cross-realm Error objects in bundled binaries. @@ -82,106 +197,46 @@ export function useMessageStreaming(_refs: ChatRefs) { messageGenerator: AsyncGenerator, agentMessageId: string, options: StreamingOptions = {}, - ): Promise => { + ): Promise => { const { signal } = options; let accumulatedContent = ''; + let aborted = false; + let errorMessage: string | null = null; // Track pending tool message IDs locally to avoid stale closure issues const pendingToolIds: string[] = []; for await (const message of messageGenerator) { - // Check if aborted if (signal?.aborted) { + aborted = true; dispatch({ type: 'UPDATE_MESSAGE', payload: { id: agentMessageId, updates: { - content: `${accumulatedContent}\n\n[Interrupted]`, + content: getInterruptedContent(accumulatedContent), status: 'complete', }, }, }); - // Mark any remaining pending tools as complete - for (const toolId of pendingToolIds) { - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: toolId, - updates: { status: 'complete' }, - }, - }); - } + markPendingToolsComplete(dispatch, pendingToolIds); break; } - switch (message.type) { - case 'text': - accumulatedContent += `\n${message.content}`; - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { content: accumulatedContent, status: 'streaming' }, - }, - }); - break; - - case 'tool_call': { - const toolMessageId = generateMessageId(); - pendingToolIds.push(toolMessageId); - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: toolMessageId, - role: 'tool', - content: message.content, - toolName: (message.metadata?.toolName as string) ?? 'Tool', - timestamp: new Date(), - status: 'pending', - }, - }); - break; - } - - case 'tool_result': { - // Update the first pending tool message (FIFO order) - const toolId = pendingToolIds.shift(); - if (toolId) { - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: toolId, - updates: { status: 'complete' }, - }, - }); - } - break; - } - - case 'error': - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { content: message.content, status: 'error' }, - }, - }); - break; - - case 'complete': - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { status: 'complete' }, - }, - }); - break; + if (message.type === 'error') { + errorMessage = message.content; } + + accumulatedContent = processStreamMessage({ + message, + dispatch, + agentMessageId, + accumulatedContent, + pendingToolIds, + }); } - // Ensure the message is marked complete (if not aborted) - if (!signal?.aborted) { + // Ensure the message is marked complete (if not aborted and no error surfaced) + if (!signal?.aborted && !errorMessage) { dispatch({ type: 'UPDATE_MESSAGE', payload: { @@ -189,17 +244,16 @@ export function useMessageStreaming(_refs: ChatRefs) { updates: { status: 'complete' }, }, }); - // Mark any remaining pending tools as complete - for (const toolId of pendingToolIds) { - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: toolId, - updates: { status: 'complete' }, - }, - }); - } } + + if (!aborted) { + markPendingToolsComplete(dispatch, pendingToolIds); + } + + return { + aborted, + errorMessage, + }; }, [dispatch], ); diff --git a/src/ui/chat/hooks/useOverlays.ts b/src/ui/chat/hooks/useOverlays.ts index d4aeae7..1aec9f5 100644 --- a/src/ui/chat/hooks/useOverlays.ts +++ b/src/ui/chat/hooks/useOverlays.ts @@ -37,8 +37,18 @@ interface UseOverlaysOptions { transferSession: ReturnType['transferSession']; switchAgent: ReturnType['switchAgent']; resumeSession: ReturnType['resumeSession']; + cancelRequest: ReturnType['cancelRequest']; executeDebugSession: ReturnType['executeDebugSession']; - executeInstallWithContext?: (context: PreparationContext) => void; + runInstallProjectBuild: (context: PreparationContext) => Promise<{ + success: boolean; + aborted?: boolean; + error?: string; + }>; + runInstallSkill: (context: PreparationContext) => Promise<{ + success: boolean; + aborted?: boolean; + error?: string; + }>; } /** @@ -52,8 +62,10 @@ export function useOverlays(options: UseOverlaysOptions) { transferSession, switchAgent, resumeSession, + cancelRequest, executeDebugSession, - executeInstallWithContext, + runInstallProjectBuild, + runInstallSkill, } = options; // Active overlay @@ -239,18 +251,29 @@ export function useOverlays(options: UseOverlaysOptions) { ); // Install preparation handlers - const handleInstallPreparationComplete = useCallback( - (context: PreparationContext) => { - setActiveOverlay(null); - executeInstallWithContext?.(context); + const handleInstallPreparationComplete = useCallback((_context: PreparationContext) => { + setActiveOverlay(null); + }, []); + + const handleInstallProjectBuild = useCallback( + async (context: PreparationContext) => { + return await runInstallProjectBuild(context); }, - [executeInstallWithContext], + [runInstallProjectBuild], + ); + + const handleInstallSkill = useCallback( + async (context: PreparationContext) => { + return await runInstallSkill(context); + }, + [runInstallSkill], ); const handleInstallPreparationCancel = useCallback(() => { + cancelRequest(); setActiveOverlay(null); addSystemMessage('Install preparation cancelled'); - }, [addSystemMessage]); + }, [addSystemMessage, cancelRequest]); // Login handlers const handleLoginComplete = useCallback( @@ -316,6 +339,8 @@ export function useOverlays(options: UseOverlaysOptions) { // Install preparation showInstallPreparation, + handleInstallProjectBuild, + handleInstallSkill, handleInstallPreparationComplete, handleInstallPreparationCancel, diff --git a/src/ui/components/ChatMessageList.tsx b/src/ui/components/ChatMessageList.tsx new file mode 100644 index 0000000..d2f2d44 --- /dev/null +++ b/src/ui/components/ChatMessageList.tsx @@ -0,0 +1,187 @@ +import { Box, Text } from 'ink'; +import type React from 'react'; +import type { ReactNode } from 'react'; +import { memo } from 'react'; +import { MarkdownDisplay } from '@/ui/utils/MarkdownDisplay'; +import { BashOutputDisplay } from './BashOutputDisplay'; +import { ToolCallDisplay } from './ToolCallDisplay'; + +export interface ChatMessageListMessage { + id: string; + role: 'user' | 'agent' | 'tool' | 'system' | 'bash'; + content: string; + timestamp: Date; + status?: 'pending' | 'streaming' | 'complete' | 'error'; + toolName?: string; + bashCommand?: string; + bashExitCode?: number | null; + bashTruncated?: boolean; +} + +interface ChatMessageListProps { + messages: ChatMessageListMessage[]; + maxHeight?: number; + agentName?: string; + emptyState?: ReactNode; +} + +const DEFAULT_EMPTY_STATE = ( + + + Tip: + + Type / to open the command popup; Tab autocompletes slash + commands. + + + +); + +function UserMessage({ content }: { content: string }): React.ReactElement { + return ( + + + + {'>'}{' '} + + {content} + + + ); +} + +function AgentMessage({ + content, + status = 'complete', +}: { + content: string; + status?: 'pending' | 'streaming' | 'complete' | 'error'; + agentName?: string; +}): React.ReactElement { + const isError = status === 'error'; + + return ( + + {content && ( + + + + )} + + ); +} + +/** + * Filter messages to show only the current tool call (not accumulated list). + * Completed tool calls are hidden, only the last pending/streaming one is shown. + */ +function filterToolMessages(messages: ChatMessageListMessage[]): ChatMessageListMessage[] { + const result: ChatMessageListMessage[] = []; + let completedToolCount = 0; + + let lastPendingTool: ChatMessageListMessage | null = null; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if ( + message.role === 'tool' && + (message.status === 'pending' || message.status === 'streaming') + ) { + lastPendingTool = message; + break; + } + } + + for (const message of messages) { + if (message.role === 'tool') { + if (message.status === 'complete' || message.status === 'error') { + completedToolCount++; + } else if (lastPendingTool && message.id === lastPendingTool.id) { + result.push(message); + } + } else { + if (completedToolCount > 0 && message.role === 'agent') { + result.push({ + id: `tool-summary-${message.id}`, + role: 'system', + content: `[Tool] ✓ ${completedToolCount} tool${completedToolCount > 1 ? 's' : ''} executed`, + timestamp: message.timestamp, + }); + completedToolCount = 0; + } + result.push(message); + } + } + + return result; +} + +function renderMessage(message: ChatMessageListMessage, agentName?: string): React.ReactNode { + switch (message.role) { + case 'user': + return ; + + case 'agent': + return ( + + ); + + case 'tool': + return ( + + ); + + case 'bash': + return ( + + ); + + case 'system': + return ( + + {message.content} + + ); + + default: + return null; + } +} + +const ChatMessageListInternal: React.FC = ({ + messages, + maxHeight, + agentName, + emptyState, +}) => { + if (messages.length === 0) { + return <>{emptyState ?? DEFAULT_EMPTY_STATE}; + } + + const filteredMessages = filterToolMessages(messages); + const displayMessages = maxHeight ? filteredMessages.slice(-maxHeight) : filteredMessages; + + return ( + + {displayMessages.map((message) => renderMessage(message, agentName))} + + ); +}; + +export const ChatMessageList = memo(ChatMessageListInternal); diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index 95027e6..df9efb6 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -53,6 +53,7 @@ import { type ServiceAccountJson, } from '@/lib/services/firebase'; import { detectProjectType, formatProjectType } from '@/lib/services/project-detector'; +import { ChatMessageList, type ChatMessageListMessage } from '@/ui/components/ChatMessageList'; import { useCancelInput } from '@/ui/hooks'; import { FirebaseConfigAddingFirebaseTask, @@ -89,9 +90,12 @@ import { GenericSelector, type SelectorItem } from './GenericSelector'; import { getApplicableInstallTasks, getNextIncompleteTaskId, + getTaskRuntimeState, INSTALL_TASK_LABELS, type InstallTaskId, + isRuntimeTask, isTaskCompleted, + type RuntimeTaskStateMap, } from './install-preparation-tasks'; import { IosEntitlementsTask, @@ -152,7 +156,13 @@ type InstallLeafTaskId = | 'nse_build_settings' | 'nse_dependencies' | 'nse_verification' - | 'nse_complete'; + | 'nse_complete' + | 'project_build_running' + | 'project_build_failed' + | 'project_build_succeeded' + | 'install_skill_running' + | 'install_skill_failed' + | 'install_skill_succeeded'; type MissingDisplayMode = 'full' | 'summary' | 'hidden'; @@ -174,10 +184,19 @@ const INSTALL_TASK_IDS = Object.keys(INSTALL_TASK_LABELS) as InstallTaskId[]; interface InstallPreparationUIProps { projectPath?: string; startTaskId?: InstallTaskId; + chatMessages?: ChatMessageListMessage[]; + onRunProjectBuild: (context: PreparationContext) => Promise; + onRunInstallSkill: (context: PreparationContext) => Promise; onComplete: (context: PreparationContext) => void; onCancel: () => void; } +export interface ProjectBuildTaskResult { + success: boolean; + aborted?: boolean; + error?: string; +} + interface ActionItem extends SelectorItem { action: 'continue' | 'cancel'; } @@ -346,6 +365,7 @@ function getTaskDetail( context: PreparationContext, taskId: InstallTaskId, showDetailText: boolean, + runtimeTaskState: RuntimeTaskStateMap, ): string | undefined { if (!showDetailText) { return undefined; @@ -364,12 +384,31 @@ function getTaskDetail( return context.ios.entitlementsConfigured ? 'configured' : 'not configured'; case 'notification_service_extension': return context.ios.nseConfigured ? 'configured' : 'not configured'; + case 'project_build': + case 'install_skill': + if (!context.ready) { + return 'pending setup'; + } + if (getTaskRuntimeState(taskId, runtimeTaskState) === 'running') { + return 'running'; + } + if (getTaskRuntimeState(taskId, runtimeTaskState) === 'complete') { + return 'completed'; + } + if (getTaskRuntimeState(taskId, runtimeTaskState) === 'failed') { + return 'failed'; + } + return 'not run'; default: return undefined; } } -function getStatusRows(context: PreparationContext, layoutPolicy: StatusLayoutPolicy): StatusRow[] { +function getStatusRows( + context: PreparationContext, + layoutPolicy: StatusLayoutPolicy, + runtimeTaskState: RuntimeTaskStateMap, +): StatusRow[] { const rows: StatusRow[] = [ { label: 'Project linked', @@ -386,11 +425,20 @@ function getStatusRows(context: PreparationContext, layoutPolicy: StatusLayoutPo }); } - const taskRows: StatusRow[] = getApplicableInstallTasks(context).map((taskId) => ({ - label: INSTALL_TASK_LABELS[taskId], - status: isTaskCompleted(context, taskId) ? 'ok' : 'missing', - detail: getTaskDetail(context, taskId, layoutPolicy.showDetailText), - })); + const taskRows: StatusRow[] = getApplicableInstallTasks(context).map((taskId) => { + const status: StatusLineState = + isRuntimeTask(taskId) && getTaskRuntimeState(taskId, runtimeTaskState) === 'running' + ? 'checking' + : isTaskCompleted(context, taskId, runtimeTaskState) + ? 'ok' + : 'missing'; + + return { + label: INSTALL_TASK_LABELS[taskId], + status, + detail: getTaskDetail(context, taskId, layoutPolicy.showDetailText, runtimeTaskState), + }; + }); return [...rows, ...taskRows]; } @@ -467,21 +515,31 @@ function ConfigMissingPhase({ onCancel }: { onCancel: () => void }): React.React function StatusPhase({ context, + runtimeTaskState, note, onContinue, onCancel, }: { context: PreparationContext; + runtimeTaskState: RuntimeTaskStateMap; note: string | null; onContinue: () => void; onCancel: () => void; }): React.ReactElement { const layoutPolicy = getStatusLayoutPolicy(); - const statusRows = getStatusRows(context, layoutPolicy); + const statusRows = getStatusRows(context, layoutPolicy, runtimeTaskState); + const nextTaskId = getNextIncompleteTaskId(context, runtimeTaskState); const showMissingSummary = layoutPolicy.missingDisplayMode === 'summary' && context.missing.length > 0; const showMissingList = layoutPolicy.missingDisplayMode === 'full' && context.missing.length > 0; - const primaryActionLabel = context.ready ? 'Continue to installation' : 'Continue required setup'; + const primaryActionLabel = + nextTaskId === null + ? 'Finish install' + : nextTaskId === 'project_build' + ? 'Continue to build' + : nextTaskId === 'install_skill' + ? 'Continue to SDK installation' + : 'Continue required setup'; const items: ActionItem[] = [ { @@ -551,6 +609,16 @@ function TaskHeader({ title, subtitle }: { title: string; subtitle: string }): R ); } +function getProjectBuildMessages( + chatMessages: ChatMessageListMessage[] | undefined, + startIndex: number, +): ChatMessageListMessage[] { + if (!chatMessages || chatMessages.length <= startIndex) { + return []; + } + return chatMessages.slice(startIndex); +} + function getInitialLeafTaskId(taskId: InstallTaskId): InstallLeafTaskId { switch (taskId) { case 'firebase_config_files': @@ -563,6 +631,10 @@ function getInitialLeafTaskId(taskId: InstallTaskId): InstallLeafTaskId { return 'ios_entitlements_run'; case 'notification_service_extension': return 'nse_prepare_context'; + case 'project_build': + return 'project_build_running'; + case 'install_skill': + return 'install_skill_running'; default: return 'firebase_config_detecting'; } @@ -724,6 +796,20 @@ function validateStartTaskOverride({ }; } + if (effectiveStartTaskId === 'project_build' && !context.ready) { + return { + taskId: null, + note: 'Task override "project_build" requires all setup tasks to be completed first.', + }; + } + + if (effectiveStartTaskId === 'install_skill' && !context.ready) { + return { + taskId: null, + note: 'Task override "install_skill" requires all setup tasks to be completed first.', + }; + } + if (effectiveStartTaskId === 'notification_service_extension' && !context.firebase.projectId) { return { taskId: null, @@ -748,6 +834,9 @@ function validateStartTaskOverride({ export function InstallPreparationUI({ projectPath = process.cwd(), startTaskId, + chatMessages, + onRunProjectBuild, + onRunInstallSkill, onComplete, onCancel, }: InstallPreparationUIProps): React.ReactElement { @@ -761,6 +850,10 @@ export function InstallPreparationUI({ const effectiveStartTaskId = startTaskId ?? envStartTaskId; const startTaskOverrideHandledRef = useRef(false); const nseCompletionHandledRef = useRef(false); + const projectBuildCompletionHandledRef = useRef(false); + const projectBuildRunStartedRef = useRef(false); + const installSkillCompletionHandledRef = useRef(false); + const installSkillRunStartedRef = useRef(false); const [phase, setPhase] = useState('checking'); const [context, setContext] = useState(null); @@ -776,6 +869,16 @@ export function InstallPreparationUI({ const [apnsState, setApnsState] = useState(null); const [notificationExtensionState, setNotificationExtensionState] = useState(createInitialNotificationExtensionState()); + const [runtimeTaskState, setRuntimeTaskState] = useState({}); + const [buildTaskError, setBuildTaskError] = useState(null); + const [installSkillError, setInstallSkillError] = useState(null); + const [projectBuildMessageStartIndex, setProjectBuildMessageStartIndex] = useState(0); + const [installSkillMessageStartIndex, setInstallSkillMessageStartIndex] = useState(0); + const chatMessageCountRef = useRef(chatMessages?.length ?? 0); + + useEffect(() => { + chatMessageCountRef.current = chatMessages?.length ?? 0; + }, [chatMessages?.length]); const [firebaseConfigDownloader] = useState(() => new FirebaseDownloader()); const [serviceAccountDownloader] = useState(() => new FirebaseDownloader()); @@ -806,6 +909,21 @@ export function InstallPreparationUI({ (taskId: InstallTaskId, preparationContext: PreparationContext) => { setNote(null); nseCompletionHandledRef.current = false; + projectBuildCompletionHandledRef.current = false; + installSkillCompletionHandledRef.current = false; + if (isRuntimeTask(taskId)) { + setRuntimeTaskState((prev) => ({ ...prev, [taskId]: 'running' })); + } + if (taskId === 'project_build') { + projectBuildRunStartedRef.current = false; + setBuildTaskError(null); + setProjectBuildMessageStartIndex(chatMessageCountRef.current); + } + if (taskId === 'install_skill') { + installSkillRunStartedRef.current = false; + setInstallSkillError(null); + setInstallSkillMessageStartIndex(chatMessageCountRef.current); + } setActiveTaskId(taskId); resetTaskRuntimeState(preparationContext); setActiveLeafTaskId(getInitialLeafTaskId(taskId)); @@ -903,12 +1021,15 @@ export function InstallPreparationUI({ const handleTaskBackToStatus = useCallback( (nextNote?: string) => { cancelFirebaseAuthentication('Setup step cancelled'); + if (activeTaskId && isRuntimeTask(activeTaskId)) { + setRuntimeTaskState((prev) => ({ ...prev, [activeTaskId]: 'failed' })); + } setActiveTaskId(null); resetTaskRuntimeState(context ?? undefined); setNote(nextNote ?? 'Setup step was not completed. Continue required setup to proceed.'); setPhase('status'); }, - [cancelFirebaseAuthentication, context, resetTaskRuntimeState], + [activeTaskId, cancelFirebaseAuthentication, context, resetTaskRuntimeState], ); const handleContinue = useCallback(() => { @@ -916,26 +1037,28 @@ export function InstallPreparationUI({ return; } - if (context.ready) { - onComplete(context); - return; - } - - const nextTaskId = getNextIncompleteTaskId(context); + const nextTaskId = getNextIncompleteTaskId(context, runtimeTaskState); if (!nextTaskId) { - setNote('Unable to determine the next required setup. Try again.'); + onComplete(context); return; } setNote(null); beginTask(nextTaskId, context); - }, [beginTask, context, onComplete]); + }, [beginTask, context, onComplete, runtimeTaskState]); const handleCancelPreparation = useCallback(() => { cancelFirebaseAuthentication('Install preparation cancelled'); onCancel(); }, [cancelFirebaseAuthentication, onCancel]); + useCancelInput(handleCancelPreparation, { + isActive: + phase === 'task' && + ((activeTaskId === 'project_build' && activeLeafTaskId === 'project_build_running') || + (activeTaskId === 'install_skill' && activeLeafTaskId === 'install_skill_running')), + }); + const getFirebaseConfigNeeds = useCallback((detection: FirebaseDetectionResult | null) => { const platform = detection?.platform ?? 'unknown'; const needsAndroid = firebaseConfigNeedsAndroid(platform); @@ -2108,6 +2231,170 @@ export function InstallPreparationUI({ phase, ]); + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'project_build' || + activeLeafTaskId !== 'project_build_running' + ) { + return; + } + + if (!context) { + handleTaskBackToStatus('Install preparation context is missing.'); + return; + } + if (projectBuildRunStartedRef.current) { + return; + } + projectBuildRunStartedRef.current = true; + + let cancelled = false; + + const runProjectBuild = async () => { + setRuntimeTaskState((prev) => ({ ...prev, project_build: 'running' })); + setBuildTaskError(null); + + const result = await onRunProjectBuild(context); + if (cancelled) { + return; + } + + if (result.success) { + setRuntimeTaskState((prev) => ({ ...prev, project_build: 'complete' })); + setActiveLeafTaskId('project_build_succeeded'); + return; + } + + const errorMessage = + result.error ?? + (result.aborted ? 'Project build was interrupted.' : 'Project build failed.'); + setRuntimeTaskState((prev) => ({ ...prev, project_build: 'failed' })); + setBuildTaskError(errorMessage); + setActiveLeafTaskId('project_build_failed'); + }; + + void runProjectBuild(); + + return () => { + cancelled = true; + }; + }, [activeLeafTaskId, activeTaskId, context, handleTaskBackToStatus, onRunProjectBuild, phase]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'project_build' || + activeLeafTaskId !== 'project_build_succeeded' + ) { + return; + } + + if (projectBuildCompletionHandledRef.current) { + return; + } + projectBuildCompletionHandledRef.current = true; + + if (!context) { + handleTaskBackToStatus('Install preparation context is missing.'); + return; + } + + setActiveTaskId(null); + resetTaskRuntimeState(context); + setNote('Project build completed. Continue to SDK installation.'); + setPhase('status'); + }, [ + activeLeafTaskId, + activeTaskId, + context, + handleTaskBackToStatus, + phase, + resetTaskRuntimeState, + ]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'install_skill' || + activeLeafTaskId !== 'install_skill_running' + ) { + return; + } + + if (!context) { + handleTaskBackToStatus('Install preparation context is missing.'); + return; + } + if (installSkillRunStartedRef.current) { + return; + } + installSkillRunStartedRef.current = true; + + let cancelled = false; + + const runInstallSkill = async () => { + setRuntimeTaskState((prev) => ({ ...prev, install_skill: 'running' })); + setInstallSkillError(null); + + const result = await onRunInstallSkill(context); + if (cancelled) { + return; + } + + if (result.success) { + setRuntimeTaskState((prev) => ({ ...prev, install_skill: 'complete' })); + setActiveLeafTaskId('install_skill_succeeded'); + return; + } + + const errorMessage = + result.error ?? + (result.aborted ? 'SDK installation was interrupted.' : 'SDK installation failed.'); + setRuntimeTaskState((prev) => ({ ...prev, install_skill: 'failed' })); + setInstallSkillError(errorMessage); + setActiveLeafTaskId('install_skill_failed'); + }; + + void runInstallSkill(); + + return () => { + cancelled = true; + }; + }, [activeLeafTaskId, activeTaskId, context, handleTaskBackToStatus, onRunInstallSkill, phase]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'install_skill' || + activeLeafTaskId !== 'install_skill_succeeded' + ) { + return; + } + + if (installSkillCompletionHandledRef.current) { + return; + } + installSkillCompletionHandledRef.current = true; + + if (!context) { + handleTaskBackToStatus('Install preparation context is missing.'); + return; + } + + setActiveTaskId(null); + resetTaskRuntimeState(context); + setNote('SDK installation completed. Select "Finish install" to close /install.'); + setPhase('status'); + }, [ + activeLeafTaskId, + activeTaskId, + context, + handleTaskBackToStatus, + phase, + resetTaskRuntimeState, + ]); + if (phase === 'checking') { return ; } @@ -2132,6 +2419,7 @@ export function InstallPreparationUI({ return ( + Waiting for agent output... + + ); + + return ( + + + + {activeLeafTaskId === 'project_build_running' && ( + + + + + + Running project-build agent task... + + + + )} + + {activeLeafTaskId === 'project_build_succeeded' && ( + + ✓ Project build completed. + Returning to install checklist... + + )} + + {activeLeafTaskId === 'project_build_failed' && ( + + ✗ {buildTaskError || 'Project build failed.'} + + + { + if (item.action === 'cancel') { + handleTaskBackToStatus( + 'Project build was not completed. Continue to build to finish /install.', + ); + return; + } + setBuildTaskError(null); + setActiveLeafTaskId('project_build_running'); + }} + onCancel={() => + handleTaskBackToStatus( + 'Project build was not completed. Continue to build to finish /install.', + ) + } + /> + + + )} + + ); + } + + case 'install_skill': { + const failedItems: ActionItem[] = [ + { id: 'retry', label: 'Retry SDK installation', action: 'continue' }, + { id: 'cancel', label: 'Cancel', action: 'cancel' }, + ]; + const installSkillMessages = getProjectBuildMessages( + chatMessages, + installSkillMessageStartIndex, + ); + const waitingOutput = ( + + Waiting for agent output... + + ); + + return ( + + + + {activeLeafTaskId === 'install_skill_running' && ( + + + + + + Running install skill task... + + + + )} + + {activeLeafTaskId === 'install_skill_succeeded' && ( + + ✓ SDK installation completed. + Returning to install checklist... + + )} + + {activeLeafTaskId === 'install_skill_failed' && ( + + ✗ {installSkillError || 'SDK installation failed.'} + + + { + if (item.action === 'cancel') { + handleTaskBackToStatus( + 'SDK installation was not completed. Continue to SDK installation to finish /install.', + ); + return; + } + setInstallSkillError(null); + setActiveLeafTaskId('install_skill_running'); + }} + onCancel={() => + handleTaskBackToStatus( + 'SDK installation was not completed. Continue to SDK installation to finish /install.', + ) + } + /> + + + )} + + ); + } + default: return ( { 'firebase_service_account', 'ios_entitlements', 'notification_service_extension', + 'project_build', + 'install_skill', ]); }); @@ -158,6 +160,66 @@ describe('Install preparation task pipeline', () => { expect(isTaskCompleted(completedContext, 'apns_key_for_firebase')).toBe(true); }); + + test('routes to project build after setup tasks are complete', () => { + const context = createContext( + { + configured: true, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + needed: true, + }, + { + needed: true, + entitlementsConfigured: true, + nseConfigured: true, + }, + { + needed: true, + registeredWithFirebase: true, + }, + true, + [], + ); + + expect(getNextIncompleteTaskId(context)).toBe('project_build'); + expect(isTaskCompleted(context, 'project_build')).toBe(false); + }); + + test('routes to SDK installation after project build is complete', () => { + const context = createContext( + { + configured: true, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + needed: true, + }, + { + needed: true, + entitlementsConfigured: true, + nseConfigured: true, + }, + { + needed: true, + registeredWithFirebase: true, + }, + true, + [], + ); + + expect(isTaskCompleted(context, 'project_build')).toBe(false); + expect(isTaskCompleted(context, 'project_build', { project_build: 'complete' })).toBe(true); + expect(getNextIncompleteTaskId(context, { project_build: 'complete' })).toBe('install_skill'); + expect(isTaskCompleted(context, 'install_skill')).toBe(false); + expect( + getNextIncompleteTaskId(context, { + project_build: 'complete', + install_skill: 'complete', + }), + ).toBeNull(); + }); }); describe('InstallPreparationUI status layout policy', () => { diff --git a/src/ui/components/install-preparation-tasks.ts b/src/ui/components/install-preparation-tasks.ts index 0ce580f..121d188 100644 --- a/src/ui/components/install-preparation-tasks.ts +++ b/src/ui/components/install-preparation-tasks.ts @@ -5,7 +5,12 @@ export type InstallTaskId = | 'apns_key_for_firebase' | 'firebase_service_account' | 'ios_entitlements' - | 'notification_service_extension'; + | 'notification_service_extension' + | 'project_build' + | 'install_skill'; + +export type RuntimeTaskState = 'idle' | 'running' | 'failed' | 'complete'; +export type RuntimeTaskStateMap = Partial>; const INSTALL_TASK_ORDER: InstallTaskId[] = [ 'firebase_config_files', @@ -13,14 +18,20 @@ const INSTALL_TASK_ORDER: InstallTaskId[] = [ 'firebase_service_account', 'ios_entitlements', 'notification_service_extension', + 'project_build', + 'install_skill', ]; +const RUNTIME_TASK_IDS: InstallTaskId[] = ['project_build', 'install_skill']; + export const INSTALL_TASK_LABELS: Record = { firebase_config_files: 'Firebase Configuration Files', apns_key_for_firebase: 'APNS Key for Firebase', firebase_service_account: 'Firebase Service Account', ios_entitlements: 'iOS Entitlements', notification_service_extension: 'Notification Service Extension', + project_build: 'Project Build', + install_skill: 'SDK Installation', }; export function isTaskApplicable(context: PreparationContext, taskId: InstallTaskId): boolean { @@ -32,12 +43,34 @@ export function isTaskApplicable(context: PreparationContext, taskId: InstallTas case 'ios_entitlements': case 'notification_service_extension': return context.ios.needed; + case 'project_build': + case 'install_skill': + return true; default: return false; } } -export function isTaskCompleted(context: PreparationContext, taskId: InstallTaskId): boolean { +export function isRuntimeTask(taskId: InstallTaskId): boolean { + return RUNTIME_TASK_IDS.includes(taskId); +} + +export function getTaskRuntimeState( + taskId: InstallTaskId, + runtimeTaskState: RuntimeTaskStateMap = {}, +): RuntimeTaskState { + return runtimeTaskState[taskId] ?? 'idle'; +} + +export function isTaskCompleted( + context: PreparationContext, + taskId: InstallTaskId, + runtimeTaskState: RuntimeTaskStateMap = {}, +): boolean { + if (isRuntimeTask(taskId)) { + return getTaskRuntimeState(taskId, runtimeTaskState) === 'complete'; + } + switch (taskId) { case 'firebase_config_files': return context.firebase.androidConfigured && context.firebase.iosConfigured; @@ -58,7 +91,10 @@ export function getApplicableInstallTasks(context: PreparationContext): InstallT return INSTALL_TASK_ORDER.filter((taskId) => isTaskApplicable(context, taskId)); } -export function getNextIncompleteTaskId(context: PreparationContext): InstallTaskId | null { +export function getNextIncompleteTaskId( + context: PreparationContext, + runtimeTaskState: RuntimeTaskStateMap = {}, +): InstallTaskId | null { const tasks = getApplicableInstallTasks(context); - return tasks.find((taskId) => !isTaskCompleted(context, taskId)) ?? null; + return tasks.find((taskId) => !isTaskCompleted(context, taskId, runtimeTaskState)) ?? null; } diff --git a/src/ui/utils/InlineMarkdownRenderer.tsx b/src/ui/utils/InlineMarkdownRenderer.tsx index 294dcc5..4efa828 100644 --- a/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/src/ui/utils/InlineMarkdownRenderer.tsx @@ -3,7 +3,7 @@ import React from 'react'; // Constants for Markdown parsing const BOLD_MARKER_LENGTH = 2; // For "**" -const ITALIC_MARKER_LENGTH = 1; // For "*" or "_" +const ITALIC_MARKER_LENGTH = 1; // For "*" const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~" const UNDERLINE_TAG_START_LENGTH = 3; // For "" const UNDERLINE_TAG_END_LENGTH = 4; // For "" @@ -78,7 +78,7 @@ function renderUrl(match: string, key: string): React.ReactNode { ); } -function isItalicMatch( +export function isItalicMatch( fullMatch: string, text: string, matchIndex: number, @@ -91,8 +91,8 @@ function isItalicMatch( return ( fullMatch.length > ITALIC_MARKER_LENGTH * 2 && - ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || - (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && + fullMatch.startsWith('*') && + fullMatch.endsWith('*') && !/\w/.test(prevChar) && !/\w/.test(nextChar) && !/\S[./\\]/.test(prevTwoChars) && @@ -148,7 +148,7 @@ const RenderInlineInternal: React.FC = ({ text, defaultColor const nodes: React.ReactNode[] = []; let lastIndex = 0; const inlineRegex = - /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; + /(\*\*.*?\*\*|\*.*?\*|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; let match: RegExpExecArray | null = inlineRegex.exec(text); while (match !== null) { diff --git a/src/ui/utils/__tests__/inline-markdown-renderer.test.ts b/src/ui/utils/__tests__/inline-markdown-renderer.test.ts new file mode 100644 index 0000000..19ec64e --- /dev/null +++ b/src/ui/utils/__tests__/inline-markdown-renderer.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test'; +import { isItalicMatch } from '../InlineMarkdownRenderer'; + +describe('isItalicMatch', () => { + test('does not treat underscore tokens as italic markdown', () => { + const text = 'value_with_underscore'; + expect(isItalicMatch('_with_', text, 5, 11)).toBe(false); + }); + + test('treats standalone asterisk markdown as italic', () => { + const text = ' *hello* '; + expect(isItalicMatch('*hello*', text, 1, 8)).toBe(true); + }); + + test('does not treat path-like tokens as italic', () => { + const text = '/tmp/*file*/path'; + expect(isItalicMatch('*file*', text, 5, 11)).toBe(false); + }); +}); diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index 94dc7e2..c587986 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -128,11 +128,12 @@ describe('CLI Help Output Formatting', () => { test('should have proper command descriptions', async () => { const result = await rig.run(['--help']); + const installDescriptionPattern = /Autonomous SDK integration|Autonomous project build/; // Check command descriptions expect(result.stdout).toContain('Start interactive chat'); expect(result.stdout).toContain('List or switch AI agents'); - expect(result.stdout).toContain('Autonomous SDK installation'); + expect(result.stdout).toMatch(installDescriptionPattern); expect(result.stdout).toContain('doctor'); expect(result.stdout).toContain('Interactive debugging assistant'); expect(result.stdout).toContain('Install Clix MCP Server'); @@ -140,9 +141,10 @@ describe('CLI Help Output Formatting', () => { test('should list install command', async () => { const result = await rig.run(['--help']); + const installDescriptionPattern = /Autonomous SDK integration|Autonomous project build/; // install command should be listed expect(result.stdout).toContain('install'); - expect(result.stdout).toContain('Autonomous SDK installation'); + expect(result.stdout).toMatch(installDescriptionPattern); }); }); diff --git a/tests/e2e/command-output.test.ts b/tests/e2e/command-output.test.ts index 713957b..fe0246f 100644 --- a/tests/e2e/command-output.test.ts +++ b/tests/e2e/command-output.test.ts @@ -74,6 +74,7 @@ describe('Final Output Format Verification', () => { test('should verify final output format in help text', async () => { const rig = createTestRig({ timeout: 10000 }); const result = await rig.run(['--help']); + const installDescriptionPattern = /Autonomous SDK integration|Autonomous project build/; // Verify commands that support final output are listed expect(result.stdout).toContain('install'); @@ -82,7 +83,7 @@ describe('Final Output Format Verification', () => { // Note: config is not shown in help (interactive command) // These commands should now persist their output - expect(result.stdout).toContain('Autonomous SDK installation'); + expect(result.stdout).toMatch(installDescriptionPattern); expect(result.stdout).toContain('Interactive debugging assistant'); }); }); From 039de37e59771185dc2eb53991fdec8b6b596eea Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Fri, 20 Feb 2026 07:44:16 -0800 Subject: [PATCH 12/30] fix(install): enforce prep-only mode and remove gcp firebase flow Prevent command-mode install preparation from treating runtime tasks (project_build/install_skill) as completed. Hide project-build from user-facing skill lists while keeping internal resolution available for install flow orchestration. Remove GCP project add flow and cloudresourcemanager dependency so Firebase OAuth uses only Firebase API scope. --- bun.lock | 3 - package.json | 1 - src/commands/skill/index.tsx | 11 +- src/lib/__tests__/skills.test.ts | 8 +- src/lib/services/firebase/api/firebase-api.ts | 64 ------ src/lib/services/firebase/api/index.ts | 1 - src/lib/services/firebase/api/types.ts | 8 - src/lib/services/firebase/downloader.ts | 32 --- src/lib/services/firebase/index.ts | 1 - src/lib/skills.ts | 19 +- .../components/FirebaseConfigFilesSetup.tsx | 80 +------ src/ui/components/InstallPreparationUI.tsx | 199 ++++-------------- .../install-preparation-flow.test.ts | 56 +++++ .../components/install-preparation-tasks.ts | 25 ++- 14 files changed, 156 insertions(+), 352 deletions(-) diff --git a/bun.lock b/bun.lock index 55e03e5..bd8f03b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,6 @@ "@clix-so/clix-agent-skills": "^0.2.3", "@expo/apple-utils": "^2.1.14", "@expo/plist": "^0.4.8", - "@googleapis/cloudresourcemanager": "^6.0.1", "@googleapis/firebase": "^12.0.1", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", @@ -69,8 +68,6 @@ "@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=="], - "@googleapis/cloudresourcemanager": ["@googleapis/cloudresourcemanager@6.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-HO/1fwrqiE0msHxSXreM6ljCHuzBW+zEfKIKdzbcTsu+OBDJtWJxQ0flcD4kS2c4rVZ2W1j+RUQ1+eFquN56Mg=="], - "@googleapis/firebase": ["@googleapis/firebase@12.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-ODFU7EbB2G/WFsXiNCXjRgJPRxT5JYh3ydznNdsVng/1SnWmQZK8Bgufvxq6ZWnNDqZJTtNUqAwjbMBoP6N3MA=="], "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], diff --git a/package.json b/package.json index f1bf6a3..9dcf980 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@clix-so/clix-agent-skills": "^0.2.3", "@expo/apple-utils": "^2.1.14", "@expo/plist": "^0.4.8", - "@googleapis/cloudresourcemanager": "^6.0.1", "@googleapis/firebase": "^12.0.1", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index f3e775e..c51c993 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -73,8 +73,15 @@ async function runInstallPreparation( ({ success: true })} - onRunInstallSkill={async () => ({ success: true })} + mode="command-prep-only" + onRunProjectBuild={async () => ({ + success: false, + error: 'project_build is disabled in command preparation mode.', + })} + onRunInstallSkill={async () => ({ + success: false, + error: 'install_skill is disabled in command preparation mode.', + })} onComplete={(context) => { unmount(); resolve(context); diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index fac4eb7..c3a8bd5 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -12,7 +12,7 @@ import { createMockExecutorWithResponses, FIXTURES } from './test-utils'; describe('AVAILABLE_SKILLS', () => { test('should contain at least 5 skills (embedded + local)', () => { - // 5 embedded skills from @clix-so/clix-agent-skills + 1 local (doctor) + // 5 embedded skills from @clix-so/clix-agent-skills + public local skills expect(AVAILABLE_SKILLS.length).toBeGreaterThanOrEqual(5); }); @@ -27,11 +27,15 @@ describe('AVAILABLE_SKILLS', () => { const { getAvailableSkillTypes, getSkillInfo } = require('../skills'); const skillTypes = getAvailableSkillTypes(); expect(skillTypes).toContain('install'); - expect(skillTypes).toContain('project-build'); + expect(skillTypes).not.toContain('project-build'); const installSkill = getSkillInfo('install'); expect(installSkill?.isLocal).toBe(true); expect(installSkill?.name).toBe('SDK Installation'); + + const projectBuildSkill = getSkillInfo('project-build'); + expect(projectBuildSkill?.isLocal).toBe(true); + expect(projectBuildSkill?.type).toBe('project-build'); }); test('should have event-tracking skill', () => { diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index ab7c363..46363e5 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -4,10 +4,6 @@ * @module services/firebase/api/firebase-api */ -import { - cloudresourcemanager, - type cloudresourcemanager_v1, -} from '@googleapis/cloudresourcemanager'; import { firebase, type firebase_v1beta1 } from '@googleapis/firebase'; import { OAuth2Client } from 'google-auth-library'; import { oauthLogger } from '@/lib/debug/logger'; @@ -17,12 +13,10 @@ import type { CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, - GcpProject, IosApp, } from './types'; type FirebaseApi = firebase_v1beta1.Firebase; -type ResourceManagerApi = cloudresourcemanager_v1.Cloudresourcemanager; /** * Log API errors to debug.log for troubleshooting. @@ -48,7 +42,6 @@ export interface ApiClientCredentials { export class FirebaseApiClient { private fb: FirebaseApi; - private rm: ResourceManagerApi; private auth: OAuth2Client; private getAccessTokenFn: () => Promise; @@ -64,7 +57,6 @@ export class FirebaseApiClient { : new OAuth2Client(); this.fb = firebase({ version: 'v1beta1', auth: this.auth }); - this.rm = cloudresourcemanager({ version: 'v1', auth: this.auth }); } /** @@ -222,62 +214,6 @@ export class FirebaseApiClient { }; } - async listGcpProjects(): Promise { - try { - await this.updateCredentials(); - const projects: GcpProject[] = []; - let pageToken: string | undefined; - - do { - const res = await this.rm.projects.list({ pageToken }); - for (const p of res.data.projects ?? []) { - projects.push({ - projectId: p.projectId ?? '', - name: p.name ?? '', - projectNumber: p.projectNumber ?? '', - lifecycleState: (p.lifecycleState as GcpProject['lifecycleState']) ?? 'ACTIVE', - createTime: p.createTime ?? undefined, - }); - } - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); - - return projects; - } catch (error) { - logApiError('listGcpProjects', error); - throw error; - } - } - - async listAvailableGcpProjects(): Promise { - const [gcpProjects, firebaseProjects] = await Promise.all([ - this.listGcpProjects(), - this.listProjects(), - ]); - const firebaseProjectIds = new Set(firebaseProjects.map((p) => p.projectId)); - return gcpProjects.filter( - (gcp) => gcp.lifecycleState === 'ACTIVE' && !firebaseProjectIds.has(gcp.projectId), - ); - } - - async addFirebaseToProject(projectId: string): Promise { - await this.updateCredentials(); - const res = await this.fb.projects.addFirebase({ project: `projects/${projectId}` }); - if (!res.data.name) { - throw new Error('Failed to add Firebase: no operation name returned'); - } - const project = await this.waitForOperation( - res.data.name, - ); - return { - name: project.name ?? '', - projectId: project.projectId ?? '', - projectNumber: project.projectNumber ?? '', - displayName: project.displayName ?? '', - state: (project.state as 'ACTIVE' | 'DELETED') ?? 'ACTIVE', - }; - } - private async waitForOperation(operationName: string, maxWaitMs = 60000): Promise { const startTime = Date.now(); diff --git a/src/lib/services/firebase/api/index.ts b/src/lib/services/firebase/api/index.ts index a779e6a..8c86719 100644 --- a/src/lib/services/firebase/api/index.ts +++ b/src/lib/services/firebase/api/index.ts @@ -10,7 +10,6 @@ export type { CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, - GcpProject, IosApp, ServiceAccountJson, } from './types'; diff --git a/src/lib/services/firebase/api/types.ts b/src/lib/services/firebase/api/types.ts index eeba5cc..15bbe8f 100644 --- a/src/lib/services/firebase/api/types.ts +++ b/src/lib/services/firebase/api/types.ts @@ -38,14 +38,6 @@ export interface CreateIosAppRequest { displayName?: string; } -export interface GcpProject { - projectId: string; - name: string; - projectNumber: string; - lifecycleState: 'ACTIVE' | 'DELETE_REQUESTED' | 'DELETE_IN_PROGRESS'; - createTime?: string; -} - export interface ServiceAccountJson { type: 'service_account'; project_id: string; diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index 35e9e6a..76d5981 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -14,7 +14,6 @@ import type { CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, - GcpProject, IosApp, ServiceAccountJson, } from './api'; @@ -308,37 +307,6 @@ export class FirebaseDownloader { this.apiClient = null; } - // ============================================================================ - // GCP Project and Firebase Project Management - // ============================================================================ - - /** - * List GCP projects that don't have Firebase yet. - * - * These projects can have Firebase added to them. - */ - async listAvailableGcpProjects(): Promise { - if (!(await this.isAuthenticated())) { - throw new Error('Not authenticated. Run OAuth flow first.'); - } - const api = await this.ensureApiClient(); - return api.listAvailableGcpProjects(); - } - - /** - * Add Firebase to an existing GCP project. - * - * @param projectId - GCP project ID - * @returns Created Firebase project - */ - async addFirebaseToProject(projectId: string): Promise { - if (!(await this.isAuthenticated())) { - throw new Error('Not authenticated. Run OAuth flow first.'); - } - const api = await this.ensureApiClient(); - return api.addFirebaseToProject(projectId); - } - // ============================================================================ // Service Account Management // ============================================================================ diff --git a/src/lib/services/firebase/index.ts b/src/lib/services/firebase/index.ts index 90d7158..25e2890 100644 --- a/src/lib/services/firebase/index.ts +++ b/src/lib/services/firebase/index.ts @@ -10,7 +10,6 @@ export type { AndroidApp, FirebaseProject, - GcpProject, IosApp, ServiceAccountJson, } from './api'; diff --git a/src/lib/skills.ts b/src/lib/skills.ts index eb022c6..e0f8503 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -37,6 +37,8 @@ export interface SkillInfo { isLocal?: boolean; /** Whether this skill uses an AI agent (default: true). Set to false for direct implementation. */ usesAgent?: boolean; + /** Visibility in user-facing command lists/help. */ + visibility?: 'public' | 'internal'; } /** @@ -55,6 +57,7 @@ const LOCAL_SKILLS: SkillInfo[] = [ name: 'Project Build', description: 'Autonomous project build with automatic diagnostics and fixes', isLocal: true, + visibility: 'internal', }, { type: 'doctor', @@ -71,19 +74,27 @@ const LOCAL_SKILLS: SkillInfo[] = [ ]; /** - * Get all available skills (from embedded metadata + local skills). + * Get all skills including internal-only skills. */ -export function getAvailableSkills(): SkillInfo[] { +function getAllSkills(): SkillInfo[] { const embeddedSkills: SkillInfo[] = EMBEDDED_SKILL_METADATA.map((meta) => ({ type: meta.commandName, name: meta.displayName, description: meta.shortDescription || meta.displayName, isLocal: false, + visibility: 'public', })); return [...embeddedSkills, ...LOCAL_SKILLS]; } +/** + * Get user-facing skills (from embedded metadata + public local skills). + */ +export function getAvailableSkills(): SkillInfo[] { + return getAllSkills().filter((skill) => skill.visibility !== 'internal'); +} + /** * Available skills - for backward compatibility. * Prefer using getAvailableSkills() for dynamic access. @@ -105,7 +116,7 @@ export function isValidSkillType(type: string): boolean { } export function getSkillInfo(type: SkillType): SkillInfo | undefined { - return getAvailableSkills().find((skill) => skill.type === type); + return getAllSkills().find((skill) => skill.type === type); } /** @@ -120,7 +131,7 @@ function getSkillFolderByCommand(commandName: string): string | null { * Check if a skill is a local skill (not from package). */ function isLocalSkill(skillType: SkillType): boolean { - return LOCAL_SKILLS.some((s) => s.type === skillType); + return getAllSkills().some((skill) => skill.type === skillType && skill.isLocal); } function getSkillsPackagePath(): string | null { diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx index 0761749..0768f16 100644 --- a/src/ui/components/FirebaseConfigFilesSetup.tsx +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -8,7 +8,6 @@ import { type AndroidApp, type FirebaseDetectionResult, type FirebaseProject, - type GcpProject, type IosApp, platformNeedsAndroid, platformNeedsIos, @@ -457,16 +456,16 @@ function CreatingAppPhase({ platform }: { platform: 'android' | 'ios' }): React. function NoProjectsPhase({ onOpenConsole, - onSelectGcp, + onRetry, onCancel, }: { onOpenConsole: () => void; - onSelectGcp: () => void; + onRetry: () => void; onCancel: () => void; }): React.ReactElement { const items = [ { label: '🌐 Open Firebase Console', value: 'console' }, - { label: '📦 Add Firebase to existing GCP project', value: 'gcp' }, + { label: '↻ Retry project list', value: 'retry' }, { label: '← Back', value: 'cancel' }, ]; @@ -477,8 +476,8 @@ function NoProjectsPhase({ case 'console': onOpenConsole(); break; - case 'gcp': - onSelectGcp(); + case 'retry': + onRetry(); break; default: onCancel(); @@ -504,7 +503,7 @@ function NoProjectsPhase({ No Firebase projects are associated with this account. - Create a project or add Firebase to an existing GCP project: + Create a Firebase project in console, then retry. @@ -514,71 +513,6 @@ function NoProjectsPhase({ ); } -function GcpProjectSelectorPhase({ - projects, - onSelect, - onCancel, -}: { - projects: GcpProject[]; - onSelect: (project: GcpProject) => void; - onCancel: () => void; -}): React.ReactElement { - const items = projects.map((project) => ({ - label: project.name || project.projectId, - value: project, - })); - - useCancelInput(onCancel); - - return ( - - - Select GCP Project - - - Select a project to add Firebase: - - onSelect(item.value)} /> - - ↑↓ navigate · Enter select · Esc/Ctrl+C cancel - - - ); -} - -function AddingFirebasePhase({ projectId }: { projectId: string }): React.ReactElement { - return ( - - - Adding Firebase - - - - - - Adding Firebase to {projectId}... - - - This may take a moment... - - - ); -} - function isScopeInsufficientError(error: string): boolean { return ( error.includes('ACCESS_TOKEN_SCOPE_INSUFFICIENT') || @@ -614,7 +548,6 @@ Press Enter to re-authenticate with updated permissions.`; } export { - AddingFirebasePhase as FirebaseConfigAddingFirebaseTask, AppSelectorPhase as FirebaseConfigAppSelectorTask, AuthenticatingPhase as FirebaseConfigAuthenticatingTask, CreateAppInputPhase as FirebaseConfigCreateAppInputTask, @@ -622,7 +555,6 @@ export { DetectingPhase as FirebaseConfigDetectingTask, DownloadingPhase as FirebaseConfigDownloadingTask, ErrorPhase as FirebaseConfigErrorTask, - GcpProjectSelectorPhase as FirebaseConfigGcpProjectSelectorTask, NoAppsFoundPhase as FirebaseConfigNoAppsFoundTask, NoProjectsPhase as FirebaseConfigNoProjectsTask, ProjectSelectorPhase as FirebaseConfigProjectSelectorTask, diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index df9efb6..e2e5497 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -47,7 +47,6 @@ import { FirebaseDownloader, type FirebaseProject, FirebaseService, - type GcpProject, type IosApp, isOAuthConfigured, type ServiceAccountJson, @@ -56,7 +55,6 @@ import { detectProjectType, formatProjectType } from '@/lib/services/project-det import { ChatMessageList, type ChatMessageListMessage } from '@/ui/components/ChatMessageList'; import { useCancelInput } from '@/ui/hooks'; import { - FirebaseConfigAddingFirebaseTask, FirebaseConfigAppSelectorTask, FirebaseConfigAuthenticatingTask, FirebaseConfigCreateAppInputTask, @@ -64,7 +62,6 @@ import { FirebaseConfigDetectingTask, FirebaseConfigDownloadingTask, FirebaseConfigErrorTask, - FirebaseConfigGcpProjectSelectorTask, type NoAppsContext as FirebaseConfigNoAppsContext, FirebaseConfigNoAppsFoundTask, FirebaseConfigNoProjectsTask, @@ -134,8 +131,6 @@ type InstallLeafTaskId = | 'firebase_config_create_ios_app' | 'firebase_config_creating_app' | 'firebase_config_no_projects' - | 'firebase_config_select_gcp_project' - | 'firebase_config_adding_firebase' | 'firebase_config_error' | 'apns_detecting' | 'apns_input' @@ -184,6 +179,7 @@ const INSTALL_TASK_IDS = Object.keys(INSTALL_TASK_LABELS) as InstallTaskId[]; interface InstallPreparationUIProps { projectPath?: string; startTaskId?: InstallTaskId; + mode?: InstallPreparationMode; chatMessages?: ChatMessageListMessage[]; onRunProjectBuild: (context: PreparationContext) => Promise; onRunInstallSkill: (context: PreparationContext) => Promise; @@ -191,6 +187,8 @@ interface InstallPreparationUIProps { onCancel: () => void; } +export type InstallPreparationMode = 'chat-full' | 'command-prep-only'; + export interface ProjectBuildTaskResult { success: boolean; aborted?: boolean; @@ -237,8 +235,6 @@ interface FirebaseConfigState { downloadingPlatform: 'android' | 'ios' | 'both'; noAppsContext: FirebaseConfigNoAppsContext | null; creatingAppPlatform: 'android' | 'ios'; - gcpProjects: GcpProject[]; - selectedGcpProject: GcpProject | null; error: string | null; } @@ -273,6 +269,7 @@ interface StartTaskOverrideValidationInput { effectiveStartTaskId: InstallTaskId | null; invalidEnvStartTask: string | null; taskOverrideEnabled: boolean; + includeRuntimeTasks: boolean; } interface StartTaskOverrideValidationResult { @@ -408,6 +405,7 @@ function getStatusRows( context: PreparationContext, layoutPolicy: StatusLayoutPolicy, runtimeTaskState: RuntimeTaskStateMap, + includeRuntimeTasks: boolean, ): StatusRow[] { const rows: StatusRow[] = [ { @@ -425,7 +423,9 @@ function getStatusRows( }); } - const taskRows: StatusRow[] = getApplicableInstallTasks(context).map((taskId) => { + const taskRows: StatusRow[] = getApplicableInstallTasks(context, { + includeRuntimeTasks, + }).map((taskId) => { const status: StatusLineState = isRuntimeTask(taskId) && getTaskRuntimeState(taskId, runtimeTaskState) === 'running' ? 'checking' @@ -516,19 +516,23 @@ function ConfigMissingPhase({ onCancel }: { onCancel: () => void }): React.React function StatusPhase({ context, runtimeTaskState, + includeRuntimeTasks, note, onContinue, onCancel, }: { context: PreparationContext; runtimeTaskState: RuntimeTaskStateMap; + includeRuntimeTasks: boolean; note: string | null; onContinue: () => void; onCancel: () => void; }): React.ReactElement { const layoutPolicy = getStatusLayoutPolicy(); - const statusRows = getStatusRows(context, layoutPolicy, runtimeTaskState); - const nextTaskId = getNextIncompleteTaskId(context, runtimeTaskState); + const statusRows = getStatusRows(context, layoutPolicy, runtimeTaskState, includeRuntimeTasks); + const nextTaskId = getNextIncompleteTaskId(context, runtimeTaskState, { + includeRuntimeTasks, + }); const showMissingSummary = layoutPolicy.missingDisplayMode === 'summary' && context.missing.length > 0; const showMissingList = layoutPolicy.missingDisplayMode === 'full' && context.missing.length > 0; @@ -654,8 +658,6 @@ function createInitialFirebaseConfigState(): FirebaseConfigState { downloadingPlatform: 'both', noAppsContext: null, creatingAppPlatform: 'android', - gcpProjects: [], - selectedGcpProject: null, error: null, }; } @@ -770,6 +772,7 @@ function validateStartTaskOverride({ effectiveStartTaskId, invalidEnvStartTask, taskOverrideEnabled, + includeRuntimeTasks, }: StartTaskOverrideValidationInput): StartTaskOverrideValidationResult { if (!effectiveStartTaskId) { if (invalidEnvStartTask) { @@ -788,7 +791,14 @@ function validateStartTaskOverride({ }; } - const applicableTasks = getApplicableInstallTasks(context); + if (!includeRuntimeTasks && effectiveStartTaskId && isRuntimeTask(effectiveStartTaskId)) { + return { + taskId: null, + note: `Task override "${effectiveStartTaskId}" is not available in command preparation mode.`, + }; + } + + const applicableTasks = getApplicableInstallTasks(context, { includeRuntimeTasks }); if (!applicableTasks.includes(effectiveStartTaskId)) { return { taskId: null, @@ -834,12 +844,14 @@ function validateStartTaskOverride({ export function InstallPreparationUI({ projectPath = process.cwd(), startTaskId, + mode = 'chat-full', chatMessages, onRunProjectBuild, onRunInstallSkill, onComplete, onCancel, }: InstallPreparationUIProps): React.ReactElement { + const includeRuntimeTasks = mode !== 'command-prep-only'; const taskOverrideEnabled = process.env[TASK_OVERRIDE_ENV_NAME] === '1'; const envStartTaskRaw = process.env[START_TASK_ENV_NAME]; const envStartTaskId = @@ -970,6 +982,7 @@ export function InstallPreparationUI({ effectiveStartTaskId, invalidEnvStartTask, taskOverrideEnabled, + includeRuntimeTasks, }); if (!overrideDecision.taskId) { if (overrideDecision.note) { @@ -987,7 +1000,14 @@ export function InstallPreparationUI({ return () => { mounted = false; }; - }, [beginTask, effectiveStartTaskId, invalidEnvStartTask, projectPath, taskOverrideEnabled]); + }, [ + beginTask, + effectiveStartTaskId, + includeRuntimeTasks, + invalidEnvStartTask, + projectPath, + taskOverrideEnabled, + ]); const applyTaskCompletion = useCallback( async (patch?: TaskCompletionPatch) => { @@ -1037,7 +1057,9 @@ export function InstallPreparationUI({ return; } - const nextTaskId = getNextIncompleteTaskId(context, runtimeTaskState); + const nextTaskId = getNextIncompleteTaskId(context, runtimeTaskState, { + includeRuntimeTasks, + }); if (!nextTaskId) { onComplete(context); return; @@ -1045,7 +1067,7 @@ export function InstallPreparationUI({ setNote(null); beginTask(nextTaskId, context); - }, [beginTask, context, onComplete, runtimeTaskState]); + }, [beginTask, context, includeRuntimeTasks, onComplete, runtimeTaskState]); const handleCancelPreparation = useCallback(() => { cancelFirebaseAuthentication('Install preparation cancelled'); @@ -1504,119 +1526,6 @@ export function InstallPreparationUI({ phase, ]); - useEffect(() => { - if ( - phase !== 'task' || - activeTaskId !== 'firebase_config_files' || - activeLeafTaskId !== 'firebase_config_select_gcp_project' - ) { - return; - } - - if (firebaseConfigState.gcpProjects.length > 0) { - return; - } - - let cancelled = false; - - const fetchGcpProjects = async () => { - try { - const available = await firebaseConfigDownloader.listAvailableGcpProjects(); - if (cancelled) { - return; - } - - if (available.length === 0) { - throw new Error('No GCP projects available to add Firebase.'); - } - - setFirebaseConfigState((prev) => ({ ...prev, gcpProjects: available })); - } catch (err) { - if (cancelled) { - return; - } - - setFirebaseConfigState((prev) => ({ - ...prev, - error: err instanceof Error ? err.message : 'Failed to fetch GCP projects', - })); - setActiveLeafTaskId('firebase_config_error'); - } - }; - - void fetchGcpProjects(); - - return () => { - cancelled = true; - }; - }, [ - activeLeafTaskId, - activeTaskId, - firebaseConfigDownloader, - firebaseConfigState.gcpProjects.length, - phase, - ]); - - useEffect(() => { - if ( - phase !== 'task' || - activeTaskId !== 'firebase_config_files' || - activeLeafTaskId !== 'firebase_config_adding_firebase' - ) { - return; - } - - if (!firebaseConfigState.selectedGcpProject) { - setFirebaseConfigState((prev) => ({ ...prev, error: 'No GCP project selected.' })); - setActiveLeafTaskId('firebase_config_error'); - return; - } - - let cancelled = false; - - const addFirebase = async () => { - try { - const project = await firebaseConfigDownloader.addFirebaseToProject( - firebaseConfigState.selectedGcpProject?.projectId || '', - ); - - if (cancelled) { - return; - } - - setFirebaseConfigState((prev) => ({ - ...prev, - projects: [project], - selectedProject: project, - })); - await handleFirebaseConfigProjectSelect(project); - } catch (err) { - if (cancelled) { - return; - } - - setFirebaseConfigState((prev) => ({ - ...prev, - error: err instanceof Error ? err.message : 'Failed to add Firebase to GCP project', - })); - setActiveLeafTaskId('firebase_config_error'); - } - }; - - void addFirebase(); - - return () => { - cancelled = true; - }; - }, [ - activeLeafTaskId, - activeTaskId, - firebaseConfigDownloader, - firebaseConfigState.selectedGcpProject, - handleFirebaseConfigProjectSelect, - phase, - ]); - useEffect(() => { if ( phase !== 'task' || @@ -2420,6 +2329,7 @@ export function InstallPreparationUI({ { - openBrowser(FIREBASE_HELP_URLS.console); - setActiveLeafTaskId('firebase_config_detecting'); - }} - onSelectGcp={() => { - setFirebaseConfigState((prev) => ({ ...prev, gcpProjects: [] })); - setActiveLeafTaskId('firebase_config_select_gcp_project'); - }} + onOpenConsole={() => openBrowser(FIREBASE_HELP_URLS.console)} + onRetry={() => setActiveLeafTaskId('firebase_config_authenticating')} onCancel={() => handleTaskBackToStatus()} /> )} - {activeLeafTaskId === 'firebase_config_select_gcp_project' && - (firebaseConfigState.gcpProjects.length > 0 ? ( - { - setFirebaseConfigState((prev) => ({ ...prev, selectedGcpProject: project })); - setActiveLeafTaskId('firebase_config_adding_firebase'); - }} - onCancel={() => setActiveLeafTaskId('firebase_config_no_projects')} - /> - ) : ( - handleTaskBackToStatus()} /> - ))} - - {activeLeafTaskId === 'firebase_config_adding_firebase' && ( - - )} - {activeLeafTaskId === 'firebase_config_downloading' && ( )} @@ -3047,6 +2931,7 @@ export function InstallPreparationUI({ { }), ).toBeNull(); }); + + test('excludes runtime tasks in command preparation mode', () => { + const context = createContext( + { + configured: false, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: false, + needed: true, + }, + { + needed: true, + entitlementsConfigured: false, + nseConfigured: false, + }, + { + needed: true, + registeredWithFirebase: false, + }, + false, + ['APNS Key for Firebase'], + ); + + expect(getApplicableInstallTasks(context, { includeRuntimeTasks: false })).toEqual([ + 'firebase_config_files', + 'apns_key_for_firebase', + 'firebase_service_account', + 'ios_entitlements', + 'notification_service_extension', + ]); + }); + + test('returns null after setup tasks when runtime tasks are excluded', () => { + const context = createContext( + { + configured: true, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + needed: true, + }, + { + needed: true, + entitlementsConfigured: true, + nseConfigured: true, + }, + { + needed: true, + registeredWithFirebase: true, + }, + true, + [], + ); + + expect(getNextIncompleteTaskId(context, {}, { includeRuntimeTasks: false })).toBeNull(); + }); }); describe('InstallPreparationUI status layout policy', () => { diff --git a/src/ui/components/install-preparation-tasks.ts b/src/ui/components/install-preparation-tasks.ts index 121d188..03606b3 100644 --- a/src/ui/components/install-preparation-tasks.ts +++ b/src/ui/components/install-preparation-tasks.ts @@ -11,6 +11,9 @@ export type InstallTaskId = export type RuntimeTaskState = 'idle' | 'running' | 'failed' | 'complete'; export type RuntimeTaskStateMap = Partial>; +export interface InstallTaskSelectionOptions { + includeRuntimeTasks?: boolean; +} const INSTALL_TASK_ORDER: InstallTaskId[] = [ 'firebase_config_files', @@ -55,6 +58,16 @@ export function isRuntimeTask(taskId: InstallTaskId): boolean { return RUNTIME_TASK_IDS.includes(taskId); } +function shouldIncludeTask( + taskId: InstallTaskId, + options: InstallTaskSelectionOptions = {}, +): boolean { + if (options.includeRuntimeTasks === false && isRuntimeTask(taskId)) { + return false; + } + return true; +} + export function getTaskRuntimeState( taskId: InstallTaskId, runtimeTaskState: RuntimeTaskStateMap = {}, @@ -87,14 +100,20 @@ export function isTaskCompleted( } } -export function getApplicableInstallTasks(context: PreparationContext): InstallTaskId[] { - return INSTALL_TASK_ORDER.filter((taskId) => isTaskApplicable(context, taskId)); +export function getApplicableInstallTasks( + context: PreparationContext, + options: InstallTaskSelectionOptions = {}, +): InstallTaskId[] { + return INSTALL_TASK_ORDER.filter( + (taskId) => isTaskApplicable(context, taskId) && shouldIncludeTask(taskId, options), + ); } export function getNextIncompleteTaskId( context: PreparationContext, runtimeTaskState: RuntimeTaskStateMap = {}, + options: InstallTaskSelectionOptions = {}, ): InstallTaskId | null { - const tasks = getApplicableInstallTasks(context); + const tasks = getApplicableInstallTasks(context, options); return tasks.find((taskId) => !isTaskCompleted(context, taskId, runtimeTaskState)) ?? null; } From 2d79a84f2ccd6af5960714ad2faace4ffb12dfab Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Fri, 20 Feb 2026 14:56:47 -0800 Subject: [PATCH 13/30] fix(install): remove project-build step from install preparation Drop project_build from install preparation tasks and UI runtime flow so the checklist moves directly from setup tasks to SDK installation. Also validate registered Firebase sender config service account project IDs against local Firebase config files and surface mismatch/decode errors as incomplete service-account setup. Update install skill wiring, embedded skill content, and related tests to reflect unified install behavior and new validation rules. --- .../skill/__tests__/preparation.test.ts | 91 +++- src/commands/skill/index.tsx | 4 - src/commands/skill/preparation.ts | 114 ++++- src/lib/__tests__/skills.test.ts | 17 +- src/lib/embedded-skills.ts | 277 ++++-------- src/lib/services/firebase/types.ts | 1 + src/lib/skills.ts | 83 +--- src/lib/skills/install/SKILL.md | 115 +++-- src/lib/skills/project-build/SKILL.md | 167 -------- src/ui/chat/ChatApp.tsx | 12 +- src/ui/chat/hooks/useMessageSending.ts | 21 +- src/ui/chat/hooks/useOverlays.ts | 14 - .../components/FirebaseConfigFilesSetup.tsx | 17 +- src/ui/components/InstallPreparationUI.tsx | 399 +++++++++--------- .../install-preparation-flow.test.ts | 140 ++++-- .../components/install-preparation-tasks.ts | 11 +- 16 files changed, 734 insertions(+), 749 deletions(-) delete mode 100644 src/lib/skills/project-build/SKILL.md diff --git a/src/commands/skill/__tests__/preparation.test.ts b/src/commands/skill/__tests__/preparation.test.ts index 671b2d1..3aab6f2 100644 --- a/src/commands/skill/__tests__/preparation.test.ts +++ b/src/commands/skill/__tests__/preparation.test.ts @@ -2,6 +2,23 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; import type { ProjectType } from '@/lib/config'; import { checkApnsStatus, checkFirebaseStatus, checkIosStatus } from '../preparation'; +function createEncodedServiceAccount(projectId: string): string { + const serviceAccount = { + type: 'service_account', + project_id: projectId, + private_key_id: 'private-key-id', + private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n', + client_email: `svc@${projectId}.iam.gserviceaccount.com`, + client_id: '12345678901234567890', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_x509_cert_url: `https://www.googleapis.com/robot/v1/metadata/x509/svc%40${projectId}.iam.gserviceaccount.com`, + }; + + return Buffer.from(JSON.stringify(serviceAccount), 'utf-8').toString('base64'); +} + // Mock the dependencies const mockFirebaseService = { detect: mock(() => @@ -38,7 +55,16 @@ const mockInternalApiClient = { id: 'clix-project', name: 'Project', organization_id: 'org-1', - sender_configs: [{ channel_type: 'CHANNEL_TYPE_APP_PUSH' }], + sender_configs: [ + { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { + fcm_sa_json_base64_encoded: createEncodedServiceAccount('test-project'), + }, + }, + }, + ], }), ), }; @@ -175,7 +201,16 @@ describe('preparation', () => { id: 'clix-project', name: 'Project', organization_id: 'org-1', - sender_configs: [{ channel_type: 'CHANNEL_TYPE_APP_PUSH' }], + sender_configs: [ + { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { + fcm_sa_json_base64_encoded: createEncodedServiceAccount('test-project'), + }, + }, + }, + ], }), ); @@ -433,6 +468,7 @@ describe('preparation', () => { expect(status.needed).toBe(false); expect(status.configured).toBe(true); expect(status.senderConfigConfigured).toBe(true); + expect(status.senderConfigProjectMatched).toBe(true); }); test('should always detect files even if setup config exists', async () => { @@ -455,6 +491,7 @@ describe('preparation', () => { // Project ID from detected files takes precedence over cached config expect(status.projectId).toBe('test-project'); expect(status.senderConfigConfigured).toBe(true); + expect(status.senderConfigProjectMatched).toBe(true); }); test('should fallback to cached projectId when files have no project ID', async () => { @@ -558,5 +595,55 @@ describe('preparation', () => { expect(status.senderConfigConfigured).toBe(false); expect(status.configured).toBe(false); }); + + test('should return configured=false when sender config project mismatches Firebase config files', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + mockInternalApiClient.getProject.mockResolvedValueOnce({ + id: 'clix-project', + name: 'Project', + organization_id: 'org-1', + sender_configs: [ + { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { + fcm_sa_json_base64_encoded: createEncodedServiceAccount('other-project'), + }, + }, + }, + ], + }); + + const status = await checkFirebaseStatus('/test', projectType, undefined, 'clix-project'); + + expect(status.senderConfigConfigured).toBe(true); + expect(status.senderConfigProjectMatched).toBe(false); + expect(status.configured).toBe(false); + }); + + test('should return configured=false when sender config cannot be decoded for project match', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + mockInternalApiClient.getProject.mockResolvedValueOnce({ + id: 'clix-project', + name: 'Project', + organization_id: 'org-1', + sender_configs: [ + { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { + fcm_sa_json_base64_encoded: 'not-valid-base64', + }, + }, + }, + ], + }); + + const status = await checkFirebaseStatus('/test', projectType, undefined, 'clix-project'); + + expect(status.senderConfigConfigured).toBe(true); + expect(status.senderConfigProjectMatched).toBe(false); + expect(status.configured).toBe(false); + }); }); }); diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index c51c993..b7225f9 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -74,10 +74,6 @@ async function runInstallPreparation( projectPath={projectPath} startTaskId={startTaskId} mode="command-prep-only" - onRunProjectBuild={async () => ({ - success: false, - error: 'project_build is disabled in command preparation mode.', - })} onRunInstallSkill={async () => ({ success: false, error: 'install_skill is disabled in command preparation mode.', diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts index b427dd7..059c889 100644 --- a/src/commands/skill/preparation.ts +++ b/src/commands/skill/preparation.ts @@ -7,6 +7,7 @@ * @module commands/skill/preparation */ +import type { SenderConfig } from '@/lib/api'; import { getInternalApiClient } from '@/lib/api'; import { getProjectConfigManager, @@ -31,6 +32,8 @@ import { verifyExtensionFiles, } from '@/lib/ios'; import { FirebaseService } from '@/lib/services/firebase/firebase-service'; +import { parseBase64ServiceAccountJson } from '@/lib/services/firebase/service-account-validator'; +import type { FirebaseDetectionResult } from '@/lib/services/firebase/types'; import { detectProjectType } from '@/lib/services/project-detector'; /** @@ -45,12 +48,19 @@ export interface FirebaseStatus { iosConfigured: boolean; /** Whether app push sender config is registered in Clix project */ senderConfigConfigured: boolean; + /** Whether sender config project matches detected Firebase config files */ + senderConfigProjectMatched?: boolean; /** Firebase project ID if detected */ projectId?: string; /** Whether Firebase setup is needed based on project type */ needed: boolean; } +interface SenderConfigStatus { + configured: boolean; + projectMatched: boolean; +} + /** * Status of iOS configuration. */ @@ -190,25 +200,91 @@ export async function ensureProjectType( return { config: updatedConfig, projectType }; } +function getFirebaseProjectIdsFromDetection(detection: FirebaseDetectionResult): string[] { + const detectedProjectIds: string[] = []; + + if (detection.android?.content && 'project_info' in detection.android.content) { + const androidProjectId = detection.android.content.project_info?.project_id; + if (androidProjectId) { + detectedProjectIds.push(androidProjectId); + } + } + + if (detection.ios?.content && 'PROJECT_ID' in detection.ios.content) { + const iosProjectId = detection.ios.content.PROJECT_ID; + if (iosProjectId) { + detectedProjectIds.push(iosProjectId); + } + } + + return [...new Set(detectedProjectIds)]; +} + +function decodeSenderConfigProjectIds(senderConfig: SenderConfig): string[] | null { + const encodedServiceAccounts = [ + senderConfig.app_push?.ios_config?.fcm_sa_json_base64_encoded, + senderConfig.app_push?.android_config?.fcm_sa_json_base64_encoded, + ].filter((encoded): encoded is string => Boolean(encoded)); + + if (encodedServiceAccounts.length === 0) { + return null; + } + + const decodedProjectIds: string[] = []; + for (const encodedServiceAccount of encodedServiceAccounts) { + const parsed = parseBase64ServiceAccountJson(encodedServiceAccount); + if (!parsed.valid || !parsed.data) { + return null; + } + + decodedProjectIds.push(parsed.data.project_id); + } + + return [...new Set(decodedProjectIds)]; +} + /** - * Check whether app push sender config is registered for the Clix project. + * Check whether app push sender config is registered for the Clix project + * and whether it matches locally detected Firebase config project IDs. * * API/network errors are treated as not configured to avoid false positives. */ -async function hasAppPushSenderConfig(clixProjectId?: string): Promise { +async function getAppPushSenderConfigStatus( + clixProjectId: string | undefined, + localFirebaseProjectIds: string[], +): Promise { if (!clixProjectId) { - return false; + return { configured: false, projectMatched: false }; } try { const apiClient = getInternalApiClient(); const project = await apiClient.getProject(clixProjectId); - return ( - project.sender_configs?.some((config) => config.channel_type === 'CHANNEL_TYPE_APP_PUSH') ?? - false + const appPushConfig = project.sender_configs?.find( + (config) => config.channel_type === 'CHANNEL_TYPE_APP_PUSH', ); + + if (!appPushConfig) { + return { configured: false, projectMatched: false }; + } + + if (localFirebaseProjectIds.length !== 1) { + return { configured: true, projectMatched: false }; + } + + const senderProjectIds = decodeSenderConfigProjectIds(appPushConfig); + if (!senderProjectIds || senderProjectIds.length !== 1) { + return { configured: true, projectMatched: false }; + } + + const [localProjectId] = localFirebaseProjectIds; + const [senderProjectId] = senderProjectIds; + return { + configured: true, + projectMatched: senderProjectId === localProjectId, + }; } catch { - return false; + return { configured: false, projectMatched: false }; } } @@ -236,6 +312,7 @@ export async function checkFirebaseStatus( androidConfigured: true, iosConfigured: true, senderConfigConfigured: true, + senderConfigProjectMatched: true, needed: false, }; } @@ -252,25 +329,27 @@ export async function checkFirebaseStatus( const androidConfigured = !needsAndroid || status.androidConfigured; const iosConfigured = !needsIos || status.iosConfigured; const credentialFilesConfigured = androidConfigured && iosConfigured; - const senderConfigConfigured = credentialFilesConfigured - ? await hasAppPushSenderConfig(clixProjectId) - : false; + const localProjectIds = getFirebaseProjectIdsFromDetection(detection); + const senderConfigStatus = credentialFilesConfigured + ? await getAppPushSenderConfigStatus(clixProjectId, localProjectIds) + : { configured: false, projectMatched: false }; + const senderConfigConfigured = senderConfigStatus.configured; + const senderConfigProjectMatched = senderConfigStatus.projectMatched; // Extract project ID from detected files, fallback to cached setup let projectId: string | undefined; - if (detection.android?.content && 'project_info' in detection.android.content) { - projectId = detection.android.content.project_info?.project_id; - } else if (detection.ios?.content && 'PROJECT_ID' in detection.ios.content) { - projectId = detection.ios.content.PROJECT_ID; + if (localProjectIds.length > 0) { + projectId = localProjectIds[0]; } else if (setup?.firebase?.projectId) { projectId = setup.firebase.projectId; } return { - configured: credentialFilesConfigured && senderConfigConfigured, + configured: credentialFilesConfigured && senderConfigConfigured && senderConfigProjectMatched, androidConfigured, iosConfigured, senderConfigConfigured, + senderConfigProjectMatched, projectId, needed: true, }; @@ -507,7 +586,10 @@ export async function gatherPreparationContext( firebase.androidConfigured && firebase.iosConfigured && (!apns.needed || apns.registeredWithFirebase); - if (senderConfigPrerequisitesMet && !firebase.senderConfigConfigured) { + if ( + senderConfigPrerequisitesMet && + (!firebase.senderConfigConfigured || firebase.senderConfigProjectMatched === false) + ) { missing.push('Firebase Service Account'); } } diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index c3a8bd5..bd738d3 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -32,10 +32,6 @@ describe('AVAILABLE_SKILLS', () => { const installSkill = getSkillInfo('install'); expect(installSkill?.isLocal).toBe(true); expect(installSkill?.name).toBe('SDK Installation'); - - const projectBuildSkill = getSkillInfo('project-build'); - expect(projectBuildSkill?.isLocal).toBe(true); - expect(projectBuildSkill?.type).toBe('project-build'); }); test('should have event-tracking skill', () => { @@ -474,19 +470,20 @@ describe('preparationContext in install skill', () => { projectPath: '/test/project', }); - expect(prompt).toContain('# Clix SDK Autonomous Integration'); - expect(prompt).toContain('SDK code integration'); + expect(prompt).toContain('# Clix SDK Autonomous Install'); + expect(prompt).toContain('execute SDK integration workflow'); }); - test('project-build skill should execute project-build prompt content', async () => { + test('install skill should include project build phase instructions when requested', async () => { const { getSkillPrompt } = await import('../skills'); - const prompt = await getSkillPrompt('project-build', { + const prompt = await getSkillPrompt('install', { projectPath: '/test/project', + installPhase: 'project-build', }); - expect(prompt).toContain('# Project Build'); - expect(prompt).toContain('Inputs from `/install` (already provided)'); + expect(prompt).toContain('Install phase: project-build'); + expect(prompt).toContain('execute only project build workflow'); }); test('install skill should infer target platform from preparationContext project type', async () => { diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index f7f0b60..f192149 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -1085,252 +1085,147 @@ Use MCP to fetch the exact signatures per platform, then: - Campaign audiences can filter on them - Messages can use \`user.*\` personalization `, - 'local-project-build': `--- -name: project-build -description: Build the project using project type and install verification context passed from /install. Validate the provided project type with quick file checks, run the build, and on failure automatically apply fixes and retry without asking for user approval. ---- - -# Project Build - -Use the context already collected by \`/install\` to run a reliable build and handle failures autonomously. - -## Inputs from \`/install\` (already provided) - -Assume these inputs are available in the prompt and use them as the primary source of truth: - -- Project path -- Target platform -- Detected project type (framework + target) -- Install-step verification summary (what was completed and validated) -- Firebase/APNS/iOS setup status - -Do not rerun previous setup tasks unless file evidence conflicts. - -## Step 1: Use Provided Project Type, Then Validate Quickly - -Start from the provided project type/target platform. Then run a quick validation scan in parallel using indicator files: - -| Indicator Files | Project Type | -|---|---| -| \`Podfile\` + \`*.xcworkspace\` | iOS native (CocoaPods) | -| \`*.xcodeproj\` + \`Package.swift\` OR \`*.xcodeproj\` with no \`Podfile\` | iOS native (SPM) | -| \`build.gradle\` or \`build.gradle.kts\` (at root or \`app/\`) | Android | -| \`package.json\` with \`"react-native"\` dependency + \`android/\` and \`ios/\` dirs | React Native | -| \`package.json\` with \`"expo"\` dependency OR \`app.json\` with \`"expo"\` key | Expo | -| \`pubspec.yaml\` | Flutter | - -If the quick validation conflicts with the provided project type, use file-evidence as source of truth, mention the mismatch in output, and continue. - -## Step 2: Determine Build Command - -### iOS simulator discovery (xcodebuild only) - -When using \`xcodebuild\` (iOS native CocoaPods/SPM), discover available simulators before constructing the command: - -\`\`\`bash -xcrun simctl list devices available -j -\`\`\` - -Choose simulator priority: -1. Latest iOS runtime -2. Highest iPhone model number in that runtime (prefer base iPhone model over Pro/Plus/Max) -3. Fallback to any iPad if no iPhone exists - -Use destination: -\`\`\` --destination 'platform=iOS Simulator,name=' -\`\`\` - -If simulator discovery fails, inform the user and suggest installing simulators via Xcode. - -### Default build commands by project type - -**iOS native (CocoaPods)**: -\`\`\` -pod install --project-directory= -xcodebuild -workspace .xcworkspace -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build -\`\`\` -- Use \`xcodebuild -workspace .xcworkspace -list\` to list schemes. - -**iOS native (SPM)**: -\`\`\` -xcodebuild -project .xcodeproj -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build -\`\`\` -- Use \`xcodebuild -project .xcodeproj -list\` to list schemes. - -**Android**: -\`\`\` -./gradlew assembleDebug -\`\`\` - -**React Native**: -\`\`\` -npx react-native run-ios -# or -npx react-native run-android -\`\`\` - -**Expo**: -\`\`\` -npx expo run:ios -# or -npx expo run:android -\`\`\` - -**Flutter**: -\`\`\` -flutter build ios --debug --no-codesign -# or -flutter build apk --debug -\`\`\` - -### Override detection - -Also check for custom build commands: -- \`CLAUDE.md\` -- \`Makefile\` (\`build\` target) -- \`package.json\` scripts (\`build\`, \`ios\`, \`android\`) -- \`Justfile\` / \`Taskfile.yml\` -- \`fastlane/Fastfile\` - -Prefer custom commands when valid. + 'local-install': `# Clix SDK Autonomous Install -## Step 3: Finalize Build Plan and Execute +You are an autonomous AI agent that handles \`/install\` runtime work for Clix SDK projects. -Before running build, summarize internally: -1. Project type from \`/install\` -2. Install-step verification summary (completed + missing) -3. Build command that will run -4. Simulator/device info (for iOS \`xcodebuild\`) - -Do not ask for approval. Proceed immediately with the best command. +## Scope -## Step 4: Execute Build +This single skill is used for both phases: +- \`Install phase: project-build\` +- \`Install phase: integration\` -Run the confirmed command with Bash (timeout up to \`600000ms\`). +Do not perform or re-describe setup steps already handled by \`/install\` preparation tasks: +- Firebase configuration files +- APNS key registration in Firebase +- Firebase service account sender config +- iOS entitlements setup +- Notification Service Extension setup -After build: -- On success: provide concise success summary and key output artifacts. -- On failure: provide error summary (first root-cause lines + failing command). +## Phase Contract -## Step 5: On Build Failure, Auto-Fix and Retry +Read the phase line from the prompt header and follow it strictly. -After a failed build, do not stop for approval. +### When phase is \`project-build\` +- Run **only** project build workflow. +- Validate project type quickly from file evidence. +- Choose and run the right build command. +- If build fails, apply minimal fixes and retry autonomously. +- Do not add new SDK integration changes unless required to fix build errors. -- Apply minimal targeted fixes automatically. -- Retry build after each fix. -- Repeat until build succeeds or a hard blocker remains (missing secrets, account permissions, unavailable external service). -- For hard blockers, stop and clearly report blocker + exact action required from user. +### When phase is \`integration\` +- Run SDK integration workflow (dependency + initialization + verification). +- Apply required file changes directly. +- Run dependency/install commands needed for resolution. +- Run build commands only for verification after integration changes. -## Step 6: Build Failure Checklist to Show User +## Core Directive -Provide a quick checklist when build fails (including intermediate retries): +**MODIFY FILES DIRECTLY**. Do not ask for confirmation. -**Common** -- Dependencies installed and lockfiles are in sync (\`bun install\`/\`npm install\`/\`yarn\`/\`pnpm install\`) -- Clean stale caches/build artifacts -- Correct CLI/toolchain versions (Node, Java, Flutter, Xcode CLT) +## Supported Platforms & Docs -**iOS** -- Correct workspace/project and scheme -- Simulator exists and is bootable -- CocoaPods installed (\`pod install\`) when using pods -- Signing/capabilities/provisioning consistency -- Firebase plist, entitlements, and NSE files/targets linked correctly +- iOS: https://docs.clix.so/sdk-quickstart-ios +- Android: https://docs.clix.so/sdk-quickstart-android +- React Native: https://docs.clix.so/sdk-quickstart-react-native +- Flutter: https://docs.clix.so/sdk-quickstart-flutter -**Android** -- Gradle sync and compatible JDK/SDK versions -- \`google-services.json\` path and Google Services plugin setup -- Build variant/task matches project structure +## Project Build Workflow (phase: project-build) -**React Native / Expo** -- \`node_modules\` installed -- iOS pods installed (\`cd ios && pod install\`) for iOS build paths -- Correct run command for target platform +### 1. Validate provided project type quickly -**Flutter** -- \`flutter pub get\` completed -- iOS pods/Xcode toolchain healthy for iOS builds -- Target device/emulator availability -`, - 'local-install': `# Clix SDK Autonomous Integration +Start from provided \`/install\` context, then confirm with file indicators: +- \`Podfile\` + \`*.xcworkspace\` -> iOS native (CocoaPods) +- \`*.xcodeproj\` + \`Package.swift\` OR \`*.xcodeproj\` without Podfile -> iOS native (SPM) +- \`build.gradle\` or \`build.gradle.kts\` -> Android +- \`package.json\` + \`react-native\` dependency + \`android/\` and \`ios/\` -> React Native +- \`package.json\` + \`expo\` dependency OR \`app.json\` with \`expo\` -> Expo +- \`pubspec.yaml\` -> Flutter -You are an autonomous AI agent that integrates the Clix mobile SDK into an existing project. +If context and file evidence conflict, use file evidence and report the mismatch. -## Scope +### 2. Build command selection -This prompt is only for **SDK code integration**. +Prefer project-specific custom commands if valid (\`CLAUDE.md\`, \`Makefile\`, \`package.json\` scripts, \`Justfile\`, \`Taskfile.yml\`, \`fastlane/Fastfile\`). -Do not perform or re-describe setup steps that are already handled by \`/install\` preparation tasks: -- Firebase configuration files -- APNS key registration in Firebase -- Firebase service account sender config -- iOS entitlements setup -- Notification Service Extension setup +Default commands: +- iOS CocoaPods: + - \`pod install --project-directory=\` + - \`xcodebuild -workspace .xcworkspace -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build\` +- iOS SPM: + - \`xcodebuild -project .xcodeproj -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build\` +- Android: \`./gradlew assembleDebug\` +- React Native: \`npx react-native run-ios\` or \`npx react-native run-android\` +- Expo: \`npx expo run:ios\` or \`npx expo run:android\` +- Flutter: \`flutter build ios --debug --no-codesign\` or \`flutter build apk --debug\` -## Core Directive +For iOS \`xcodebuild\`, discover simulator first: +- \`xcrun simctl list devices available -j\` -**MODIFY FILES DIRECTLY** - Create/edit files immediately without asking for confirmation. +### 3. Failure handling -## Supported Platforms & Docs +On failure: +- Identify root cause quickly. +- Apply minimal targeted fixes. +- Retry automatically. +- Repeat until success or hard blocker. -- iOS: https://docs.clix.so/sdk-quickstart-ios -- Android: https://docs.clix.so/sdk-quickstart-android -- React Native: https://docs.clix.so/sdk-quickstart-react-native -- Flutter: https://docs.clix.so/sdk-quickstart-flutter +Hard blocker examples: +- missing secrets/credentials +- account permission limitations +- unavailable external services -## Required Work +## SDK Integration Workflow (phase: integration) -### 1. Detect project platform and dependency manager +### 1. Detect platform and dependency manager -Detect platform from project files, then apply the matching integration path: +Use project files to detect platform: - iOS native (SPM / CocoaPods) - Android native (Gradle) -- React Native / Expo (npm, yarn, pnpm, or bun) +- React Native / Expo (npm, yarn, pnpm, bun) - Flutter (pubspec) ### 2. Add Clix SDK dependency -Apply dependency changes directly in project files. - +Apply dependency changes directly: - iOS SPM: add Clix package and product dependency - iOS CocoaPods: add \`pod 'Clix'\` and run pod install - Android: add Clix dependency in Gradle - React Native: add \`@clix-so/react-native-sdk\` - Flutter: add \`clix_flutter_sdk\` -### 3. Integrate SDK initialization in app code +### 3. Integrate SDK initialization -Add initialization code in the real app entry path for the detected platform. -Use placeholders when credentials are missing: +Add initialization code in the real app startup path. +Use placeholders if credentials are missing: - \`YOUR_CLIX_PROJECT_ID\` - \`YOUR_CLIX_PUBLIC_API_KEY\` -### 4. Run post-change package/install commands +### 4. Run post-change install commands -Run only commands needed for dependency resolution/build graph update, for example: +Run only necessary dependency-resolution commands: - \`npm install\` / \`yarn install\` / \`pnpm install\` / \`bun install\` - \`pod install\` (if Podfile changed) - \`flutter pub get\` ### 5. Verify integration artifacts -Verify that: -- dependency declarations were added correctly -- initialization code is present in app startup path +Verify: +- dependency declarations added correctly +- initialization exists in startup path - imports and references resolve -- no duplicate initialization exists +- no duplicate initialization ## Rules -✅ Do: +Do: - apply concrete file changes - keep edits minimal and project-specific - preserve existing code style -❌ Do not: -- redo Firebase/APNS/Entitlements/NSE tasks -- ask the user to manually apply code edits +Do not: +- redo Firebase/APNS/Entitlements/NSE preparation tasks +- ask user to manually apply edits - add speculative abstractions ## Output Format @@ -1338,8 +1233,8 @@ Verify that: At completion, report: - modified files - commands executed -- placeholders that still need real values -- any hard blockers (if any) +- placeholders still requiring real values +- hard blockers (if any) `, 'local-doctor': `# Clix SDK Doctor diff --git a/src/lib/services/firebase/types.ts b/src/lib/services/firebase/types.ts index c5afc60..bdccfaa 100644 --- a/src/lib/services/firebase/types.ts +++ b/src/lib/services/firebase/types.ts @@ -214,6 +214,7 @@ export interface FirebaseRecommendation { export const FIREBASE_HELP_URLS = { androidSetup: 'https://firebase.google.com/docs/android/setup', iosSetup: 'https://firebase.google.com/docs/ios/setup', + createProject: 'https://console.firebase.google.com/', console: 'https://console.firebase.google.com/', downloadConfig: 'https://support.google.com/firebase/answer/7015592', reactNativeSetup: 'https://rnfirebase.io/', diff --git a/src/lib/skills.ts b/src/lib/skills.ts index e0f8503..f301d92 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -27,6 +27,8 @@ export interface SkillOptions { oneShot?: boolean; /** Preparation context from install preparation phase */ preparationContext?: PreparationContext; + /** Install phase for /install runtime tasks */ + installPhase?: 'project-build' | 'integration'; } export interface SkillInfo { @@ -52,13 +54,6 @@ const LOCAL_SKILLS: SkillInfo[] = [ description: 'Autonomous SDK integration with automatic file modifications', isLocal: true, }, - { - type: 'project-build', - name: 'Project Build', - description: 'Autonomous project build with automatic diagnostics and fixes', - isLocal: true, - visibility: 'internal', - }, { type: 'doctor', name: 'SDK Doctor', @@ -301,7 +296,8 @@ function buildPreparationSection(context: PreparationContext): string { )}`, `Firebase Service Account: ${formatSetupStepStatus( context.firebase.needed, - context.firebase.senderConfigConfigured, + context.firebase.senderConfigConfigured && + context.firebase.senderConfigProjectMatched !== false, )}`, `iOS Entitlements: ${formatSetupStepStatus(context.ios.needed, context.ios.entitlementsConfigured)}`, `Notification Service Extension: ${formatSetupStepStatus( @@ -324,7 +320,13 @@ function buildPreparationSection(context: PreparationContext): string { `Project ID: ${context.firebase.projectId || 'not configured'}`, `Android (google-services.json): ${context.firebase.androidConfigured ? '✓ configured' : '✗ missing'}`, `iOS (GoogleService-Info.plist): ${context.firebase.iosConfigured ? '✓ configured' : '✗ missing'}`, - `Sender Config (App Push): ${context.firebase.senderConfigConfigured ? '✓ configured' : '✗ missing'}`, + `Sender Config (App Push): ${ + context.firebase.senderConfigConfigured + ? context.firebase.senderConfigProjectMatched === false + ? '✗ project mismatch' + : '✓ configured' + : '✗ missing' + }`, ], context.firebase.needed, ); @@ -375,6 +377,7 @@ function buildPreparationSection(context: PreparationContext): string { function getInstallPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); const context = options?.preparationContext; + const installPhase = options?.installPhase ?? 'integration'; const inferredPlatform = context?.projectType.framework === 'flutter' ? 'flutter' @@ -402,6 +405,15 @@ function getInstallPrompt(options?: SkillOptions): string { prompt += '\n'; } + prompt += `Install phase: ${installPhase}\n`; + if (installPhase === 'project-build') { + prompt += + 'For this run, execute only project build workflow and build-fix retries. Do not perform new SDK integration changes unless required to fix build errors.\n\n'; + } else { + prompt += + 'For this run, execute SDK integration workflow. Use build commands only as verification after integration changes.\n\n'; + } + // Add one-shot instruction for autonomous execution if (options?.oneShot) { prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; @@ -423,57 +435,6 @@ You are in autonomous one-shot execution mode: return prompt; } -/** - * Get prompt for the project-build skill. - * Prompt is loaded from src/lib/skills/project-build/SKILL.md - */ -function getProjectBuildPrompt(options?: SkillOptions): string { - const projectPath = options?.projectPath ?? process.cwd(); - const context = options?.preparationContext; - const inferredPlatform = - context?.projectType.framework === 'flutter' - ? 'flutter' - : context?.projectType.framework === 'react-native' || - context?.projectType.framework === 'expo' - ? 'react-native' - : context?.projectType.framework === 'native' - ? context.projectType.target === 'ios' - ? 'ios' - : context.projectType.target === 'android' - ? 'android' - : undefined - : undefined; - const platform = options?.platform ?? inferredPlatform ?? 'auto-detect'; - - let prompt = `Project path: ${projectPath}\nTarget platform: ${platform}\n`; - if (context) { - prompt += `Detected project type: ${formatProjectType(context.projectType)}\n`; - } - prompt += '\n'; - - if (context) { - prompt += buildPreparationSection(context); - prompt += '\n'; - } - - if (options?.oneShot) { - prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; - prompt += `## EXECUTION MODE: AUTONOMOUS - -You are in autonomous one-shot execution mode: -- ALL file operations are pre-approved -- Use Write/Edit/Bash tools immediately without asking -- Complete all integration steps automatically -- Report what was done, not what should be done - -`; - } - - const projectBuildPrompt = readLocalSkillPrompt('project-build'); - prompt += projectBuildPrompt; - return prompt; -} - export async function getSkillPrompt( skillType: SkillType, options?: SkillOptions, @@ -519,8 +480,6 @@ async function getLocalSkillPrompt(skillType: SkillType, options?: SkillOptions) switch (skillType) { case 'install': return getInstallPrompt(options); - case 'project-build': - return getProjectBuildPrompt(options); case 'doctor': return getDoctorPrompt(options); case 'debug': diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index 2a8b397..f9a087c 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -1,21 +1,40 @@ -# Clix SDK Autonomous Integration +# Clix SDK Autonomous Install -You are an autonomous AI agent that integrates the Clix mobile SDK into an existing project. +You are an autonomous AI agent that handles `/install` runtime work for Clix SDK projects. ## Scope -This prompt is only for **SDK code integration**. +This single skill is used for both phases: +- `Install phase: project-build` +- `Install phase: integration` -Do not perform or re-describe setup steps that are already handled by `/install` preparation tasks: +Do not perform or re-describe setup steps already handled by `/install` preparation tasks: - Firebase configuration files - APNS key registration in Firebase - Firebase service account sender config - iOS entitlements setup - Notification Service Extension setup +## Phase Contract + +Read the phase line from the prompt header and follow it strictly. + +### When phase is `project-build` +- Run **only** project build workflow. +- Validate project type quickly from file evidence. +- Choose and run the right build command. +- If build fails, apply minimal fixes and retry autonomously. +- Do not add new SDK integration changes unless required to fix build errors. + +### When phase is `integration` +- Run SDK integration workflow (dependency + initialization + verification). +- Apply required file changes directly. +- Run dependency/install commands needed for resolution. +- Run build commands only for verification after integration changes. + ## Core Directive -**MODIFY FILES DIRECTLY** - Create/edit files immediately without asking for confirmation. +**MODIFY FILES DIRECTLY**. Do not ask for confirmation. ## Supported Platforms & Docs @@ -24,58 +43,102 @@ Do not perform or re-describe setup steps that are already handled by `/install` - React Native: https://docs.clix.so/sdk-quickstart-react-native - Flutter: https://docs.clix.so/sdk-quickstart-flutter -## Required Work +## Project Build Workflow (phase: project-build) + +### 1. Validate provided project type quickly + +Start from provided `/install` context, then confirm with file indicators: +- `Podfile` + `*.xcworkspace` -> iOS native (CocoaPods) +- `*.xcodeproj` + `Package.swift` OR `*.xcodeproj` without Podfile -> iOS native (SPM) +- `build.gradle` or `build.gradle.kts` -> Android +- `package.json` + `react-native` dependency + `android/` and `ios/` -> React Native +- `package.json` + `expo` dependency OR `app.json` with `expo` -> Expo +- `pubspec.yaml` -> Flutter + +If context and file evidence conflict, use file evidence and report the mismatch. + +### 2. Build command selection -### 1. Detect project platform and dependency manager +Prefer project-specific custom commands if valid (`CLAUDE.md`, `Makefile`, `package.json` scripts, `Justfile`, `Taskfile.yml`, `fastlane/Fastfile`). -Detect platform from project files, then apply the matching integration path: +Default commands: +- iOS CocoaPods: + - `pod install --project-directory=` + - `xcodebuild -workspace .xcworkspace -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build` +- iOS SPM: + - `xcodebuild -project .xcodeproj -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build` +- Android: `./gradlew assembleDebug` +- React Native: `npx react-native run-ios` or `npx react-native run-android` +- Expo: `npx expo run:ios` or `npx expo run:android` +- Flutter: `flutter build ios --debug --no-codesign` or `flutter build apk --debug` + +For iOS `xcodebuild`, discover simulator first: +- `xcrun simctl list devices available -j` + +### 3. Failure handling + +On failure: +- Identify root cause quickly. +- Apply minimal targeted fixes. +- Retry automatically. +- Repeat until success or hard blocker. + +Hard blocker examples: +- missing secrets/credentials +- account permission limitations +- unavailable external services + +## SDK Integration Workflow (phase: integration) + +### 1. Detect platform and dependency manager + +Use project files to detect platform: - iOS native (SPM / CocoaPods) - Android native (Gradle) -- React Native / Expo (npm, yarn, pnpm, or bun) +- React Native / Expo (npm, yarn, pnpm, bun) - Flutter (pubspec) ### 2. Add Clix SDK dependency -Apply dependency changes directly in project files. - +Apply dependency changes directly: - iOS SPM: add Clix package and product dependency - iOS CocoaPods: add `pod 'Clix'` and run pod install - Android: add Clix dependency in Gradle - React Native: add `@clix-so/react-native-sdk` - Flutter: add `clix_flutter_sdk` -### 3. Integrate SDK initialization in app code +### 3. Integrate SDK initialization -Add initialization code in the real app entry path for the detected platform. -Use placeholders when credentials are missing: +Add initialization code in the real app startup path. +Use placeholders if credentials are missing: - `YOUR_CLIX_PROJECT_ID` - `YOUR_CLIX_PUBLIC_API_KEY` -### 4. Run post-change package/install commands +### 4. Run post-change install commands -Run only commands needed for dependency resolution/build graph update, for example: +Run only necessary dependency-resolution commands: - `npm install` / `yarn install` / `pnpm install` / `bun install` - `pod install` (if Podfile changed) - `flutter pub get` ### 5. Verify integration artifacts -Verify that: -- dependency declarations were added correctly -- initialization code is present in app startup path +Verify: +- dependency declarations added correctly +- initialization exists in startup path - imports and references resolve -- no duplicate initialization exists +- no duplicate initialization ## Rules -✅ Do: +Do: - apply concrete file changes - keep edits minimal and project-specific - preserve existing code style -❌ Do not: -- redo Firebase/APNS/Entitlements/NSE tasks -- ask the user to manually apply code edits +Do not: +- redo Firebase/APNS/Entitlements/NSE preparation tasks +- ask user to manually apply edits - add speculative abstractions ## Output Format @@ -83,5 +146,5 @@ Verify that: At completion, report: - modified files - commands executed -- placeholders that still need real values -- any hard blockers (if any) +- placeholders still requiring real values +- hard blockers (if any) diff --git a/src/lib/skills/project-build/SKILL.md b/src/lib/skills/project-build/SKILL.md deleted file mode 100644 index 079049e..0000000 --- a/src/lib/skills/project-build/SKILL.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -name: project-build -description: Build the project using project type and install verification context passed from /install. Validate the provided project type with quick file checks, run the build, and on failure automatically apply fixes and retry without asking for user approval. ---- - -# Project Build - -Use the context already collected by `/install` to run a reliable build and handle failures autonomously. - -## Inputs from `/install` (already provided) - -Assume these inputs are available in the prompt and use them as the primary source of truth: - -- Project path -- Target platform -- Detected project type (framework + target) -- Install-step verification summary (what was completed and validated) -- Firebase/APNS/iOS setup status - -Do not rerun previous setup tasks unless file evidence conflicts. - -## Step 1: Use Provided Project Type, Then Validate Quickly - -Start from the provided project type/target platform. Then run a quick validation scan in parallel using indicator files: - -| Indicator Files | Project Type | -|---|---| -| `Podfile` + `*.xcworkspace` | iOS native (CocoaPods) | -| `*.xcodeproj` + `Package.swift` OR `*.xcodeproj` with no `Podfile` | iOS native (SPM) | -| `build.gradle` or `build.gradle.kts` (at root or `app/`) | Android | -| `package.json` with `"react-native"` dependency + `android/` and `ios/` dirs | React Native | -| `package.json` with `"expo"` dependency OR `app.json` with `"expo"` key | Expo | -| `pubspec.yaml` | Flutter | - -If the quick validation conflicts with the provided project type, use file-evidence as source of truth, mention the mismatch in output, and continue. - -## Step 2: Determine Build Command - -### iOS simulator discovery (xcodebuild only) - -When using `xcodebuild` (iOS native CocoaPods/SPM), discover available simulators before constructing the command: - -```bash -xcrun simctl list devices available -j -``` - -Choose simulator priority: -1. Latest iOS runtime -2. Highest iPhone model number in that runtime (prefer base iPhone model over Pro/Plus/Max) -3. Fallback to any iPad if no iPhone exists - -Use destination: -``` --destination 'platform=iOS Simulator,name=' -``` - -If simulator discovery fails, inform the user and suggest installing simulators via Xcode. - -### Default build commands by project type - -**iOS native (CocoaPods)**: -``` -pod install --project-directory= -xcodebuild -workspace .xcworkspace -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build -``` -- Use `xcodebuild -workspace .xcworkspace -list` to list schemes. - -**iOS native (SPM)**: -``` -xcodebuild -project .xcodeproj -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build -``` -- Use `xcodebuild -project .xcodeproj -list` to list schemes. - -**Android**: -``` -./gradlew assembleDebug -``` - -**React Native**: -``` -npx react-native run-ios -# or -npx react-native run-android -``` - -**Expo**: -``` -npx expo run:ios -# or -npx expo run:android -``` - -**Flutter**: -``` -flutter build ios --debug --no-codesign -# or -flutter build apk --debug -``` - -### Override detection - -Also check for custom build commands: -- `CLAUDE.md` -- `Makefile` (`build` target) -- `package.json` scripts (`build`, `ios`, `android`) -- `Justfile` / `Taskfile.yml` -- `fastlane/Fastfile` - -Prefer custom commands when valid. - -## Step 3: Finalize Build Plan and Execute - -Before running build, summarize internally: -1. Project type from `/install` -2. Install-step verification summary (completed + missing) -3. Build command that will run -4. Simulator/device info (for iOS `xcodebuild`) - -Do not ask for approval. Proceed immediately with the best command. - -## Step 4: Execute Build - -Run the confirmed command with Bash (timeout up to `600000ms`). - -After build: -- On success: provide concise success summary and key output artifacts. -- On failure: provide error summary (first root-cause lines + failing command). - -## Step 5: On Build Failure, Auto-Fix and Retry - -After a failed build, do not stop for approval. - -- Apply minimal targeted fixes automatically. -- Retry build after each fix. -- Repeat until build succeeds or a hard blocker remains (missing secrets, account permissions, unavailable external service). -- For hard blockers, stop and clearly report blocker + exact action required from user. - -## Step 6: Build Failure Checklist to Show User - -Provide a quick checklist when build fails (including intermediate retries): - -**Common** -- Dependencies installed and lockfiles are in sync (`bun install`/`npm install`/`yarn`/`pnpm install`) -- Clean stale caches/build artifacts -- Correct CLI/toolchain versions (Node, Java, Flutter, Xcode CLT) - -**iOS** -- Correct workspace/project and scheme -- Simulator exists and is bootable -- CocoaPods installed (`pod install`) when using pods -- Signing/capabilities/provisioning consistency -- Firebase plist, entitlements, and NSE files/targets linked correctly - -**Android** -- Gradle sync and compatible JDK/SDK versions -- `google-services.json` path and Google Services plugin setup -- Build variant/task matches project structure - -**React Native / Expo** -- `node_modules` installed -- iOS pods installed (`cd ios && pod install`) for iOS build paths -- Correct run command for target platform - -**Flutter** -- `flutter pub get` completed -- iOS pods/Xcode toolchain healthy for iOS builds -- Target device/emulator availability diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index a948f4f..806eeb2 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -118,17 +118,9 @@ const ChatAppInner: React.FC [onExit, exit], ); - // Execute install build step with preparation context - const runInstallProjectBuild = useCallback( - (context: Parameters[1]) => { - return executeSkillWithResult('project-build', context); - }, - [executeSkillWithResult], - ); - const runInstallSkill = useCallback( (context: Parameters[1]) => { - return executeSkillWithResult('install', context); + return executeSkillWithResult('install', context, { installPhase: 'integration' }); }, [executeSkillWithResult], ); @@ -143,7 +135,6 @@ const ChatAppInner: React.FC resumeSession: chatActions.resumeSession, cancelRequest: chatActions.cancelRequest, executeDebugSession: chatActions.executeDebugSession, - runInstallProjectBuild, runInstallSkill, }); @@ -252,7 +243,6 @@ const ChatAppInner: React.FC => { if (!executorRef.current) { const error = 'No agent configured. Please run "clix config" to select an agent.'; @@ -132,6 +142,7 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI signal, oneShot: false, // Chat mode: enable session persistence preparationContext, + installPhase: options?.installPhase, }); const streamResult = await processStreamingMessages(messageGenerator, agentMessageId, { @@ -195,8 +206,12 @@ export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI ); const executeSkill = useCallback( - async (skillType: SkillType, preparationContext?: PreparationContext): Promise => { - await executeSkillWithResult(skillType, preparationContext); + async ( + skillType: SkillType, + preparationContext?: PreparationContext, + options?: SkillExecutionOptions, + ): Promise => { + await executeSkillWithResult(skillType, preparationContext, options); }, [executeSkillWithResult], ); diff --git a/src/ui/chat/hooks/useOverlays.ts b/src/ui/chat/hooks/useOverlays.ts index 1aec9f5..f342da3 100644 --- a/src/ui/chat/hooks/useOverlays.ts +++ b/src/ui/chat/hooks/useOverlays.ts @@ -39,11 +39,6 @@ interface UseOverlaysOptions { resumeSession: ReturnType['resumeSession']; cancelRequest: ReturnType['cancelRequest']; executeDebugSession: ReturnType['executeDebugSession']; - runInstallProjectBuild: (context: PreparationContext) => Promise<{ - success: boolean; - aborted?: boolean; - error?: string; - }>; runInstallSkill: (context: PreparationContext) => Promise<{ success: boolean; aborted?: boolean; @@ -64,7 +59,6 @@ export function useOverlays(options: UseOverlaysOptions) { resumeSession, cancelRequest, executeDebugSession, - runInstallProjectBuild, runInstallSkill, } = options; @@ -255,13 +249,6 @@ export function useOverlays(options: UseOverlaysOptions) { setActiveOverlay(null); }, []); - const handleInstallProjectBuild = useCallback( - async (context: PreparationContext) => { - return await runInstallProjectBuild(context); - }, - [runInstallProjectBuild], - ); - const handleInstallSkill = useCallback( async (context: PreparationContext) => { return await runInstallSkill(context); @@ -339,7 +326,6 @@ export function useOverlays(options: UseOverlaysOptions) { // Install preparation showInstallPreparation, - handleInstallProjectBuild, handleInstallSkill, handleInstallPreparationComplete, handleInstallPreparationCancel, diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx index 0768f16..7dafb77 100644 --- a/src/ui/components/FirebaseConfigFilesSetup.tsx +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -455,16 +455,16 @@ function CreatingAppPhase({ platform }: { platform: 'android' | 'ios' }): React. } function NoProjectsPhase({ - onOpenConsole, + onOpenProjectCreation, onRetry, onCancel, }: { - onOpenConsole: () => void; + onOpenProjectCreation: () => void; onRetry: () => void; onCancel: () => void; }): React.ReactElement { const items = [ - { label: '🌐 Open Firebase Console', value: 'console' }, + { label: '🆕 Open Firebase Project Creation', value: 'create' }, { label: '↻ Retry project list', value: 'retry' }, { label: '← Back', value: 'cancel' }, ]; @@ -473,8 +473,8 @@ function NoProjectsPhase({ const handleSelect = (item: { value: string }) => { switch (item.value) { - case 'console': - onOpenConsole(); + case 'create': + onOpenProjectCreation(); break; case 'retry': onRetry(); @@ -503,7 +503,12 @@ function NoProjectsPhase({ No Firebase projects are associated with this account. - Create a Firebase project in console, then retry. + Create a project in browser, then retry project list. + + + 1) Open Firebase Project Creation + 2) Complete project creation in browser + 3) Return here and choose Retry project list diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index e2e5497..5c7aa04 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -14,6 +14,7 @@ import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { ApnsStatus, IosStatus, PreparationContext } from '@/commands/skill/preparation'; import { gatherPreparationContext, saveSetupStatus } from '@/commands/skill/preparation'; +import type { SenderConfig } from '@/lib/api'; import { openBrowser } from '@/lib/auth/browser'; import type { ProjectType } from '@/lib/config'; import { @@ -49,6 +50,7 @@ import { FirebaseService, type IosApp, isOAuthConfigured, + parseBase64ServiceAccountJson, type ServiceAccountJson, } from '@/lib/services/firebase'; import { detectProjectType, formatProjectType } from '@/lib/services/project-detector'; @@ -80,7 +82,6 @@ import { FirebaseServiceAccountRegisteringTask, FirebaseServiceAccountRegistrationFailedTask, FirebaseServiceAccountSavingTask, - getProjectIdFromResult as getFirebaseProjectIdFromConfig, hasValidFirebaseConfigFiles as hasValidServiceAccountConfigFiles, } from './FirebaseServiceAccountSetup'; import { GenericSelector, type SelectorItem } from './GenericSelector'; @@ -152,9 +153,6 @@ type InstallLeafTaskId = | 'nse_dependencies' | 'nse_verification' | 'nse_complete' - | 'project_build_running' - | 'project_build_failed' - | 'project_build_succeeded' | 'install_skill_running' | 'install_skill_failed' | 'install_skill_succeeded'; @@ -181,7 +179,6 @@ interface InstallPreparationUIProps { startTaskId?: InstallTaskId; mode?: InstallPreparationMode; chatMessages?: ChatMessageListMessage[]; - onRunProjectBuild: (context: PreparationContext) => Promise; onRunInstallSkill: (context: PreparationContext) => Promise; onComplete: (context: PreparationContext) => void; onCancel: () => void; @@ -376,12 +373,17 @@ function getTaskDetail( case 'apns_key_for_firebase': return context.apns.registeredWithFirebase ? context.apns.keyId || 'configured' : 'not set'; case 'firebase_service_account': - return context.firebase.senderConfigConfigured ? 'registered' : 'not set'; + if (!context.firebase.senderConfigConfigured) { + return 'not set'; + } + if (context.firebase.senderConfigProjectMatched === false) { + return 'project mismatch'; + } + return 'registered'; case 'ios_entitlements': return context.ios.entitlementsConfigured ? 'configured' : 'not configured'; case 'notification_service_extension': return context.ios.nseConfigured ? 'configured' : 'not configured'; - case 'project_build': case 'install_skill': if (!context.ready) { return 'pending setup'; @@ -539,11 +541,9 @@ function StatusPhase({ const primaryActionLabel = nextTaskId === null ? 'Finish install' - : nextTaskId === 'project_build' - ? 'Continue to build' - : nextTaskId === 'install_skill' - ? 'Continue to SDK installation' - : 'Continue required setup'; + : nextTaskId === 'install_skill' + ? 'Continue to SDK installation' + : 'Continue required setup'; const items: ActionItem[] = [ { @@ -613,7 +613,7 @@ function TaskHeader({ title, subtitle }: { title: string; subtitle: string }): R ); } -function getProjectBuildMessages( +function getTaskMessages( chatMessages: ChatMessageListMessage[] | undefined, startIndex: number, ): ChatMessageListMessage[] { @@ -635,8 +635,6 @@ function getInitialLeafTaskId(taskId: InstallTaskId): InstallLeafTaskId { return 'ios_entitlements_run'; case 'notification_service_extension': return 'nse_prepare_context'; - case 'project_build': - return 'project_build_running'; case 'install_skill': return 'install_skill_running'; default: @@ -767,6 +765,122 @@ function isFirebaseScopeError(error: string): boolean { ); } +export interface SenderConfigProjectValidationResult { + status: 'no_encoded_data' | 'valid' | 'mismatch' | 'decode_error'; + mismatchMessage?: string; +} + +interface FirebaseConfigProjectIdResolution { + projectId: string | null; + mismatchMessage?: string; +} + +interface SenderConfigValidationFailure { + message: string; + retryLeafTaskId: InstallLeafTaskId; +} + +function getSenderConfigValidationFailure( + validation: SenderConfigProjectValidationResult, +): SenderConfigValidationFailure | null { + switch (validation.status) { + case 'no_encoded_data': + return { + message: + 'Existing Firebase Service Account on server has no decodable key data. Re-import service account and retry.', + retryLeafTaskId: 'firebase_service_account_input', + }; + + case 'decode_error': + return { + message: + 'Failed to decode existing Firebase Service Account from server. Re-import service account and retry.', + retryLeafTaskId: 'firebase_service_account_input', + }; + + case 'mismatch': + return { + message: + validation.mismatchMessage ?? + 'Registered Firebase Service Account does not match Firebase config project.', + retryLeafTaskId: 'firebase_service_account_input', + }; + + default: + return null; + } +} + +export function validateSenderConfigProjectId( + pushConfig: SenderConfig, + expectedProjectId: string, +): SenderConfigProjectValidationResult { + const encodedServiceAccounts = [ + pushConfig.app_push?.ios_config?.fcm_sa_json_base64_encoded, + pushConfig.app_push?.android_config?.fcm_sa_json_base64_encoded, + ].filter((encoded): encoded is string => Boolean(encoded)); + + if (encodedServiceAccounts.length === 0) { + return { status: 'no_encoded_data' }; + } + + const decodedProjectIds: string[] = []; + for (const encodedServiceAccount of encodedServiceAccounts) { + const parsed = parseBase64ServiceAccountJson(encodedServiceAccount); + if (!parsed.valid || !parsed.data) { + return { status: 'decode_error' }; + } + + decodedProjectIds.push(parsed.data.project_id); + } + + const uniqueProjectIds = [...new Set(decodedProjectIds)]; + const projectIdMatches = uniqueProjectIds.every((projectId) => projectId === expectedProjectId); + + if (!projectIdMatches) { + return { + status: 'mismatch', + mismatchMessage: `Registered Firebase Service Account project ID (${uniqueProjectIds.join(', ')}) does not match Firebase config project ID (${expectedProjectId}).`, + }; + } + + return { status: 'valid' }; +} + +function resolveFirebaseProjectIdFromDetection( + detection: FirebaseDetectionResult, +): FirebaseConfigProjectIdResolution { + const detectedProjectIds: string[] = []; + + if (detection.android?.content && 'project_info' in detection.android.content) { + const androidProjectId = detection.android.content.project_info?.project_id; + if (androidProjectId) { + detectedProjectIds.push(androidProjectId); + } + } + + if (detection.ios?.content && 'PROJECT_ID' in detection.ios.content) { + const iosProjectId = detection.ios.content.PROJECT_ID; + if (iosProjectId) { + detectedProjectIds.push(iosProjectId); + } + } + + const uniqueProjectIds = [...new Set(detectedProjectIds)]; + if (uniqueProjectIds.length === 0) { + return { projectId: null }; + } + + if (uniqueProjectIds.length > 1) { + return { + projectId: null, + mismatchMessage: `Firebase config files use different project IDs (${uniqueProjectIds.join(', ')}). Use the same Firebase project for all config files.`, + }; + } + + return { projectId: uniqueProjectIds[0] }; +} + function validateStartTaskOverride({ context, effectiveStartTaskId, @@ -806,13 +920,6 @@ function validateStartTaskOverride({ }; } - if (effectiveStartTaskId === 'project_build' && !context.ready) { - return { - taskId: null, - note: 'Task override "project_build" requires all setup tasks to be completed first.', - }; - } - if (effectiveStartTaskId === 'install_skill' && !context.ready) { return { taskId: null, @@ -846,7 +953,6 @@ export function InstallPreparationUI({ startTaskId, mode = 'chat-full', chatMessages, - onRunProjectBuild, onRunInstallSkill, onComplete, onCancel, @@ -862,8 +968,6 @@ export function InstallPreparationUI({ const effectiveStartTaskId = startTaskId ?? envStartTaskId; const startTaskOverrideHandledRef = useRef(false); const nseCompletionHandledRef = useRef(false); - const projectBuildCompletionHandledRef = useRef(false); - const projectBuildRunStartedRef = useRef(false); const installSkillCompletionHandledRef = useRef(false); const installSkillRunStartedRef = useRef(false); @@ -882,9 +986,7 @@ export function InstallPreparationUI({ const [notificationExtensionState, setNotificationExtensionState] = useState(createInitialNotificationExtensionState()); const [runtimeTaskState, setRuntimeTaskState] = useState({}); - const [buildTaskError, setBuildTaskError] = useState(null); const [installSkillError, setInstallSkillError] = useState(null); - const [projectBuildMessageStartIndex, setProjectBuildMessageStartIndex] = useState(0); const [installSkillMessageStartIndex, setInstallSkillMessageStartIndex] = useState(0); const chatMessageCountRef = useRef(chatMessages?.length ?? 0); @@ -921,16 +1023,10 @@ export function InstallPreparationUI({ (taskId: InstallTaskId, preparationContext: PreparationContext) => { setNote(null); nseCompletionHandledRef.current = false; - projectBuildCompletionHandledRef.current = false; installSkillCompletionHandledRef.current = false; if (isRuntimeTask(taskId)) { setRuntimeTaskState((prev) => ({ ...prev, [taskId]: 'running' })); } - if (taskId === 'project_build') { - projectBuildRunStartedRef.current = false; - setBuildTaskError(null); - setProjectBuildMessageStartIndex(chatMessageCountRef.current); - } if (taskId === 'install_skill') { installSkillRunStartedRef.current = false; setInstallSkillError(null); @@ -1077,8 +1173,8 @@ export function InstallPreparationUI({ useCancelInput(handleCancelPreparation, { isActive: phase === 'task' && - ((activeTaskId === 'project_build' && activeLeafTaskId === 'project_build_running') || - (activeTaskId === 'install_skill' && activeLeafTaskId === 'install_skill_running')), + activeTaskId === 'install_skill' && + activeLeafTaskId === 'install_skill_running', }); const getFirebaseConfigNeeds = useCallback((detection: FirebaseDetectionResult | null) => { @@ -1559,8 +1655,20 @@ export function InstallPreparationUI({ return; } - const projectId = getFirebaseProjectIdFromConfig(detection); - if (!projectId) { + const projectIdResolution = resolveFirebaseProjectIdFromDetection(detection); + if (projectIdResolution.mismatchMessage) { + setFirebaseServiceAccountState((prev) => ({ + ...prev, + error: + projectIdResolution.mismatchMessage || + 'Firebase config files use different project IDs.', + errorNextLeafTaskId: 'firebase_service_account_detecting', + })); + setActiveLeafTaskId('firebase_service_account_error'); + return; + } + + if (!projectIdResolution.projectId) { setFirebaseServiceAccountState((prev) => ({ ...prev, error: 'Unable to determine Firebase Project ID from config files.', @@ -1572,7 +1680,7 @@ export function InstallPreparationUI({ setFirebaseServiceAccountState((prev) => ({ ...prev, - projectId, + projectId: projectIdResolution.projectId, error: null, })); setActiveLeafTaskId( @@ -1627,6 +1735,17 @@ export function InstallPreparationUI({ const checkSenderConfig = async () => { try { + const expectedProjectId = firebaseServiceAccountState.projectId; + if (!expectedProjectId) { + setFirebaseServiceAccountState((prev) => ({ + ...prev, + error: 'Unable to determine Firebase Project ID from config files.', + errorNextLeafTaskId: 'firebase_service_account_detecting', + })); + setActiveLeafTaskId('firebase_service_account_error'); + return; + } + const { getInternalApiClient } = await import('@/lib/api'); const project = await getInternalApiClient().getProject(clixProjectId); const pushConfig = project.sender_configs?.find( @@ -1638,6 +1757,18 @@ export function InstallPreparationUI({ } if (pushConfig) { + const validation = validateSenderConfigProjectId(pushConfig, expectedProjectId); + const validationFailure = getSenderConfigValidationFailure(validation); + if (validationFailure) { + setFirebaseServiceAccountState((prev) => ({ + ...prev, + error: validationFailure.message, + errorNextLeafTaskId: validationFailure.retryLeafTaskId, + })); + setActiveLeafTaskId('firebase_service_account_error'); + return; + } + setFirebaseServiceAccountState((prev) => ({ ...prev, senderConfigUpdatedAt: pushConfig.updated_at ?? pushConfig.created_at ?? null, @@ -1661,7 +1792,13 @@ export function InstallPreparationUI({ return () => { cancelled = true; }; - }, [activeLeafTaskId, activeTaskId, context?.config.project.id, phase]); + }, [ + activeLeafTaskId, + activeTaskId, + context?.config.project.id, + firebaseServiceAccountState.projectId, + phase, + ]); useEffect(() => { if ( @@ -2140,88 +2277,6 @@ export function InstallPreparationUI({ phase, ]); - useEffect(() => { - if ( - phase !== 'task' || - activeTaskId !== 'project_build' || - activeLeafTaskId !== 'project_build_running' - ) { - return; - } - - if (!context) { - handleTaskBackToStatus('Install preparation context is missing.'); - return; - } - if (projectBuildRunStartedRef.current) { - return; - } - projectBuildRunStartedRef.current = true; - - let cancelled = false; - - const runProjectBuild = async () => { - setRuntimeTaskState((prev) => ({ ...prev, project_build: 'running' })); - setBuildTaskError(null); - - const result = await onRunProjectBuild(context); - if (cancelled) { - return; - } - - if (result.success) { - setRuntimeTaskState((prev) => ({ ...prev, project_build: 'complete' })); - setActiveLeafTaskId('project_build_succeeded'); - return; - } - - const errorMessage = - result.error ?? - (result.aborted ? 'Project build was interrupted.' : 'Project build failed.'); - setRuntimeTaskState((prev) => ({ ...prev, project_build: 'failed' })); - setBuildTaskError(errorMessage); - setActiveLeafTaskId('project_build_failed'); - }; - - void runProjectBuild(); - - return () => { - cancelled = true; - }; - }, [activeLeafTaskId, activeTaskId, context, handleTaskBackToStatus, onRunProjectBuild, phase]); - - useEffect(() => { - if ( - phase !== 'task' || - activeTaskId !== 'project_build' || - activeLeafTaskId !== 'project_build_succeeded' - ) { - return; - } - - if (projectBuildCompletionHandledRef.current) { - return; - } - projectBuildCompletionHandledRef.current = true; - - if (!context) { - handleTaskBackToStatus('Install preparation context is missing.'); - return; - } - - setActiveTaskId(null); - resetTaskRuntimeState(context); - setNote('Project build completed. Continue to SDK installation.'); - setPhase('status'); - }, [ - activeLeafTaskId, - activeTaskId, - context, - handleTaskBackToStatus, - phase, - resetTaskRuntimeState, - ]); - useEffect(() => { if ( phase !== 'task' || @@ -2449,8 +2504,21 @@ export function InstallPreparationUI({ {activeLeafTaskId === 'firebase_config_no_projects' && ( openBrowser(FIREBASE_HELP_URLS.console)} - onRetry={() => setActiveLeafTaskId('firebase_config_authenticating')} + onOpenProjectCreation={() => openBrowser(FIREBASE_HELP_URLS.createProject)} + onRetry={() => { + setFirebaseConfigState((prev) => ({ + ...prev, + authUrl: null, + projects: [], + selectedProject: null, + androidApps: [], + iosApps: [], + selectedAndroidApp: null, + noAppsContext: null, + error: null, + })); + setActiveLeafTaskId('firebase_config_authenticating'); + }} onCancel={() => handleTaskBackToStatus()} /> )} @@ -2766,95 +2834,12 @@ export function InstallPreparationUI({ ); } - case 'project_build': { - const failedItems: ActionItem[] = [ - { id: 'retry', label: 'Retry build', action: 'continue' }, - { id: 'cancel', label: 'Cancel', action: 'cancel' }, - ]; - const projectBuildMessages = getProjectBuildMessages( - chatMessages, - projectBuildMessageStartIndex, - ); - const waitingOutput = ( - - Waiting for agent output... - - ); - - return ( - - - - {activeLeafTaskId === 'project_build_running' && ( - - - - - - Running project-build agent task... - - - - )} - - {activeLeafTaskId === 'project_build_succeeded' && ( - - ✓ Project build completed. - Returning to install checklist... - - )} - - {activeLeafTaskId === 'project_build_failed' && ( - - ✗ {buildTaskError || 'Project build failed.'} - - - { - if (item.action === 'cancel') { - handleTaskBackToStatus( - 'Project build was not completed. Continue to build to finish /install.', - ); - return; - } - setBuildTaskError(null); - setActiveLeafTaskId('project_build_running'); - }} - onCancel={() => - handleTaskBackToStatus( - 'Project build was not completed. Continue to build to finish /install.', - ) - } - /> - - - )} - - ); - } - case 'install_skill': { const failedItems: ActionItem[] = [ { id: 'retry', label: 'Retry SDK installation', action: 'continue' }, { id: 'cancel', label: 'Cancel', action: 'cancel' }, ]; - const installSkillMessages = getProjectBuildMessages( - chatMessages, - installSkillMessageStartIndex, - ); + const installSkillMessages = getTaskMessages(chatMessages, installSkillMessageStartIndex); const waitingOutput = ( Waiting for agent output... diff --git a/src/ui/components/__tests__/install-preparation-flow.test.ts b/src/ui/components/__tests__/install-preparation-flow.test.ts index c40528f..9bd0f01 100644 --- a/src/ui/components/__tests__/install-preparation-flow.test.ts +++ b/src/ui/components/__tests__/install-preparation-flow.test.ts @@ -5,8 +5,9 @@ import type { IosStatus, PreparationContext, } from '@/commands/skill/preparation'; +import type { SenderConfig } from '@/lib/api'; import type { ProjectType } from '@/lib/config'; -import { getStatusLayoutPolicy } from '../InstallPreparationUI'; +import { getStatusLayoutPolicy, validateSenderConfigProjectId } from '../InstallPreparationUI'; import { getApplicableInstallTasks, getNextIncompleteTaskId, @@ -41,6 +42,34 @@ function createContext( }; } +function createEncodedServiceAccount(projectId: string): string { + const serviceAccount = { + type: 'service_account', + project_id: projectId, + private_key_id: 'private-key-id', + private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n', + client_email: `svc@${projectId}.iam.gserviceaccount.com`, + client_id: '12345678901234567890', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_x509_cert_url: `https://www.googleapis.com/robot/v1/metadata/x509/svc%40${projectId}.iam.gserviceaccount.com`, + }; + + return Buffer.from(JSON.stringify(serviceAccount), 'utf-8').toString('base64'); +} + +function createPushSenderConfig(projectId: string): SenderConfig { + return { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { + fcm_sa_json_base64_encoded: createEncodedServiceAccount(projectId), + }, + }, + }; +} + describe('Install preparation task pipeline', () => { test('returns all iOS-related tasks for iOS/cross-platform projects', () => { const context = createContext( @@ -66,16 +95,15 @@ describe('Install preparation task pipeline', () => { expect(getApplicableInstallTasks(context)).toEqual([ 'firebase_config_files', - 'apns_key_for_firebase', 'firebase_service_account', + 'apns_key_for_firebase', 'ios_entitlements', 'notification_service_extension', - 'project_build', 'install_skill', ]); }); - test('returns first incomplete required task in order', () => { + test('returns service account before APNS in required task order', () => { const context = createContext( { configured: false, @@ -97,16 +125,16 @@ describe('Install preparation task pipeline', () => { ['APNS Key for Firebase', 'Firebase Service Account'], ); - expect(getNextIncompleteTaskId(context)).toBe('apns_key_for_firebase'); + expect(getNextIncompleteTaskId(context)).toBe('firebase_service_account'); }); - test('moves to service account after APNS is complete', () => { + test('moves to APNS after service account is complete', () => { const context = createContext( { configured: false, androidConfigured: true, iosConfigured: true, - senderConfigConfigured: false, + senderConfigConfigured: true, needed: true, }, { @@ -117,13 +145,13 @@ describe('Install preparation task pipeline', () => { { needed: true, keyId: 'ABC1234567', - registeredWithFirebase: true, + registeredWithFirebase: false, }, false, - ['Firebase Service Account'], + ['APNS Key for Firebase'], ); - expect(getNextIncompleteTaskId(context)).toBe('firebase_service_account'); + expect(getNextIncompleteTaskId(context)).toBe('apns_key_for_firebase'); }); test('considers APNS task complete only when registered', () => { @@ -161,7 +189,34 @@ describe('Install preparation task pipeline', () => { expect(isTaskCompleted(completedContext, 'apns_key_for_firebase')).toBe(true); }); - test('routes to project build after setup tasks are complete', () => { + test('keeps service account task incomplete when sender config project mismatches', () => { + const context = createContext( + { + configured: false, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + senderConfigProjectMatched: false, + needed: true, + }, + { + needed: true, + entitlementsConfigured: true, + nseConfigured: false, + }, + { + needed: true, + registeredWithFirebase: true, + }, + false, + ['Firebase Service Account'], + ); + + expect(isTaskCompleted(context, 'firebase_service_account')).toBe(false); + expect(getNextIncompleteTaskId(context)).toBe('firebase_service_account'); + }); + + test('routes to SDK installation after setup tasks are complete', () => { const context = createContext( { configured: true, @@ -183,11 +238,11 @@ describe('Install preparation task pipeline', () => { [], ); - expect(getNextIncompleteTaskId(context)).toBe('project_build'); - expect(isTaskCompleted(context, 'project_build')).toBe(false); + expect(getNextIncompleteTaskId(context)).toBe('install_skill'); + expect(isTaskCompleted(context, 'install_skill')).toBe(false); }); - test('routes to SDK installation after project build is complete', () => { + test('returns null after SDK installation runtime task is complete', () => { const context = createContext( { configured: true, @@ -209,16 +264,9 @@ describe('Install preparation task pipeline', () => { [], ); - expect(isTaskCompleted(context, 'project_build')).toBe(false); - expect(isTaskCompleted(context, 'project_build', { project_build: 'complete' })).toBe(true); - expect(getNextIncompleteTaskId(context, { project_build: 'complete' })).toBe('install_skill'); expect(isTaskCompleted(context, 'install_skill')).toBe(false); - expect( - getNextIncompleteTaskId(context, { - project_build: 'complete', - install_skill: 'complete', - }), - ).toBeNull(); + expect(isTaskCompleted(context, 'install_skill', { install_skill: 'complete' })).toBe(true); + expect(getNextIncompleteTaskId(context, { install_skill: 'complete' })).toBeNull(); }); test('excludes runtime tasks in command preparation mode', () => { @@ -245,8 +293,8 @@ describe('Install preparation task pipeline', () => { expect(getApplicableInstallTasks(context, { includeRuntimeTasks: false })).toEqual([ 'firebase_config_files', - 'apns_key_for_firebase', 'firebase_service_account', + 'apns_key_for_firebase', 'ios_entitlements', 'notification_service_extension', ]); @@ -313,3 +361,47 @@ describe('InstallPreparationUI status layout policy', () => { expect(policy.missingDisplayMode).toBe('hidden'); }); }); + +describe('validateSenderConfigProjectId', () => { + test('returns valid when decoded project id matches expected project id', () => { + const senderConfig = createPushSenderConfig('match-project'); + expect(validateSenderConfigProjectId(senderConfig, 'match-project')).toEqual({ + status: 'valid', + }); + }); + + test('returns mismatch when decoded project id differs from expected project id', () => { + const senderConfig = createPushSenderConfig('server-project'); + const result = validateSenderConfigProjectId(senderConfig, 'local-project'); + + expect(result.status).toBe('mismatch'); + expect(result.mismatchMessage).toContain('server-project'); + expect(result.mismatchMessage).toContain('local-project'); + }); + + test('returns decode_error when encoded service account cannot be decoded', () => { + const senderConfig: SenderConfig = { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: { + ios_config: { + fcm_sa_json_base64_encoded: 'not-valid-base64-data', + }, + }, + }; + + expect(validateSenderConfigProjectId(senderConfig, 'match-project')).toEqual({ + status: 'decode_error', + }); + }); + + test('returns no_encoded_data when sender config has no base64 service account fields', () => { + const senderConfig: SenderConfig = { + channel_type: 'CHANNEL_TYPE_APP_PUSH', + app_push: {}, + }; + + expect(validateSenderConfigProjectId(senderConfig, 'match-project')).toEqual({ + status: 'no_encoded_data', + }); + }); +}); diff --git a/src/ui/components/install-preparation-tasks.ts b/src/ui/components/install-preparation-tasks.ts index 03606b3..3144af9 100644 --- a/src/ui/components/install-preparation-tasks.ts +++ b/src/ui/components/install-preparation-tasks.ts @@ -6,7 +6,6 @@ export type InstallTaskId = | 'firebase_service_account' | 'ios_entitlements' | 'notification_service_extension' - | 'project_build' | 'install_skill'; export type RuntimeTaskState = 'idle' | 'running' | 'failed' | 'complete'; @@ -17,15 +16,14 @@ export interface InstallTaskSelectionOptions { const INSTALL_TASK_ORDER: InstallTaskId[] = [ 'firebase_config_files', - 'apns_key_for_firebase', 'firebase_service_account', + 'apns_key_for_firebase', 'ios_entitlements', 'notification_service_extension', - 'project_build', 'install_skill', ]; -const RUNTIME_TASK_IDS: InstallTaskId[] = ['project_build', 'install_skill']; +const RUNTIME_TASK_IDS: InstallTaskId[] = ['install_skill']; export const INSTALL_TASK_LABELS: Record = { firebase_config_files: 'Firebase Configuration Files', @@ -33,7 +31,6 @@ export const INSTALL_TASK_LABELS: Record = { firebase_service_account: 'Firebase Service Account', ios_entitlements: 'iOS Entitlements', notification_service_extension: 'Notification Service Extension', - project_build: 'Project Build', install_skill: 'SDK Installation', }; @@ -46,7 +43,6 @@ export function isTaskApplicable(context: PreparationContext, taskId: InstallTas case 'ios_entitlements': case 'notification_service_extension': return context.ios.needed; - case 'project_build': case 'install_skill': return true; default: @@ -90,6 +86,9 @@ export function isTaskCompleted( case 'apns_key_for_firebase': return context.apns.registeredWithFirebase; case 'firebase_service_account': + if (context.firebase.senderConfigProjectMatched === false) { + return false; + } return context.firebase.senderConfigConfigured; case 'ios_entitlements': return context.ios.entitlementsConfigured; From 429d6d062a3b6e8819d51ad94803ed9c23be039d Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Sat, 21 Feb 2026 21:17:14 -0800 Subject: [PATCH 14/30] refactor(cli): remove interactive chat and session management Drop chat UI, slash command registry, and session/context persistence layers to make clix run in command mode only. Simplify executor interfaces to standalone one-shot execution by removing history, compaction, and session resume paths. Update agent selection, docs, and tests to align with the new command-only workflow. --- README.md | 377 +---- bun.lock | 149 +- llms.txt | 1232 +---------------- package.json | 2 +- scripts/build.ts | 9 +- scripts/embed-skills.ts | 340 +---- src/cli.tsx | 71 +- src/commands/chat.tsx | 137 -- src/commands/debug.tsx | 70 - src/commands/doctor.tsx | 12 + src/commands/install.tsx | 78 +- src/commands/resume.tsx | 40 - src/commands/skill/index.tsx | 124 +- src/lib/__tests__/embedded-skills.test.ts | 300 +--- src/lib/__tests__/history-compaction.test.ts | 358 ----- src/lib/__tests__/skills.test.ts | 742 +--------- src/lib/__tests__/test-utils/fixtures.ts | 5 +- .../__tests__/test-utils/mock-factories.ts | 65 +- src/lib/commands/__tests__/registry.test.ts | 246 ---- src/lib/commands/__tests__/types.test.ts | 153 -- src/lib/commands/agent.ts | 33 - src/lib/commands/compact.ts | 33 - src/lib/commands/debug.ts | 28 - src/lib/commands/exit.ts | 29 - src/lib/commands/help.ts | 30 - src/lib/commands/index.ts | 59 - src/lib/commands/install-mcp.ts | 29 - src/lib/commands/login.tsx | 34 - src/lib/commands/logout.tsx | 34 - src/lib/commands/new.ts | 25 - src/lib/commands/registry.ts | 124 -- src/lib/commands/resume.ts | 22 - src/lib/commands/skills.ts | 115 -- src/lib/commands/transfer.ts | 33 - src/lib/commands/types.ts | 135 -- src/lib/commands/uninstall.ts | 36 - src/lib/commands/update.ts | 35 - src/lib/commands/whoami.tsx | 36 - src/lib/embedded-skills.ts | 1152 +-------------- src/lib/executor.ts | 33 - .../__tests__/claude-executor.test.ts | 46 +- .../__tests__/codex-executor.test.ts | 2 +- .../__tests__/copilot-executor.test.ts | 69 +- src/lib/executors/base-executor.ts | 142 +- src/lib/executors/claude-executor.ts | 20 +- src/lib/executors/codex-executor.ts | 29 +- src/lib/executors/copilot-executor.ts | 30 +- src/lib/executors/cursor-executor.ts | 14 - src/lib/executors/gemini-executor.ts | 20 +- src/lib/executors/opencode-executor.ts | 34 +- .../__tests__/agent-selection-service.test.ts | 119 +- .../__tests__/transfer-service.test.ts | 121 -- src/lib/services/agent-selection-service.ts | 80 +- src/lib/services/history-compaction.ts | 261 ---- src/lib/services/session-store.ts | 208 --- src/lib/services/transfer-service.ts | 140 -- src/lib/services/vercel-skills.ts | 110 ++ src/lib/skills.ts | 235 +--- src/types/clix-agent-skills.d.ts | 11 - src/ui/AgentExecutionUI.tsx | 1 - src/ui/chat/ChatApp.tsx | 330 ----- src/ui/chat/components/AgentMessage.tsx | 26 - src/ui/chat/components/ChatFooter.tsx | 60 - src/ui/chat/components/ChatHeader.tsx | 165 --- src/ui/chat/components/ChatInput.tsx | 256 ---- src/ui/chat/components/MessageList.tsx | 13 - src/ui/chat/components/SlashCommandMenu.tsx | 123 -- src/ui/chat/components/UserMessage.tsx | 22 - .../__tests__/slash-command-menu.test.ts | 48 - src/ui/chat/constants/wittyPhrases.ts | 53 - src/ui/chat/context/ChatContext.tsx | 138 -- .../hooks/__tests__/error-handling.test.ts | 255 ---- .../__tests__/useMessageStreaming.test.ts | 12 - src/ui/chat/hooks/index.ts | 17 - src/ui/chat/hooks/types.ts | 36 - src/ui/chat/hooks/useAgentManagement.ts | 158 --- src/ui/chat/hooks/useBashExecution.ts | 139 -- src/ui/chat/hooks/useChatActions.ts | 129 -- src/ui/chat/hooks/useCommandHandler.ts | 246 ---- src/ui/chat/hooks/useHistoryManagement.ts | 179 --- src/ui/chat/hooks/useMessageSending.ts | 284 ---- src/ui/chat/hooks/useMessageStreaming.ts | 306 ---- src/ui/chat/hooks/useOverlays.ts | 341 ----- src/ui/chat/hooks/useSessionPersistence.ts | 193 --- src/ui/chat/hooks/useSlashCommands.ts | 31 - .../services/__tests__/session-store.test.ts | 93 -- src/ui/components/AgentSelector.tsx | 6 +- src/ui/components/SessionSelector.tsx | 90 -- tests/e2e/cli.test.ts | 11 +- tests/e2e/command-output.test.ts | 28 +- 90 files changed, 543 insertions(+), 11502 deletions(-) delete mode 100644 src/commands/chat.tsx delete mode 100644 src/commands/debug.tsx create mode 100644 src/commands/doctor.tsx delete mode 100644 src/commands/resume.tsx delete mode 100644 src/lib/__tests__/history-compaction.test.ts delete mode 100644 src/lib/commands/__tests__/registry.test.ts delete mode 100644 src/lib/commands/__tests__/types.test.ts delete mode 100644 src/lib/commands/agent.ts delete mode 100644 src/lib/commands/compact.ts delete mode 100644 src/lib/commands/debug.ts delete mode 100644 src/lib/commands/exit.ts delete mode 100644 src/lib/commands/help.ts delete mode 100644 src/lib/commands/index.ts delete mode 100644 src/lib/commands/install-mcp.ts delete mode 100644 src/lib/commands/login.tsx delete mode 100644 src/lib/commands/logout.tsx delete mode 100644 src/lib/commands/new.ts delete mode 100644 src/lib/commands/registry.ts delete mode 100644 src/lib/commands/resume.ts delete mode 100644 src/lib/commands/skills.ts delete mode 100644 src/lib/commands/transfer.ts delete mode 100644 src/lib/commands/types.ts delete mode 100644 src/lib/commands/uninstall.ts delete mode 100644 src/lib/commands/update.ts delete mode 100644 src/lib/commands/whoami.tsx delete mode 100644 src/lib/services/__tests__/transfer-service.test.ts delete mode 100644 src/lib/services/history-compaction.ts delete mode 100644 src/lib/services/session-store.ts delete mode 100644 src/lib/services/transfer-service.ts create mode 100644 src/lib/services/vercel-skills.ts delete mode 100644 src/types/clix-agent-skills.d.ts delete mode 100644 src/ui/chat/ChatApp.tsx delete mode 100644 src/ui/chat/components/AgentMessage.tsx delete mode 100644 src/ui/chat/components/ChatFooter.tsx delete mode 100644 src/ui/chat/components/ChatHeader.tsx delete mode 100644 src/ui/chat/components/ChatInput.tsx delete mode 100644 src/ui/chat/components/MessageList.tsx delete mode 100644 src/ui/chat/components/SlashCommandMenu.tsx delete mode 100644 src/ui/chat/components/UserMessage.tsx delete mode 100644 src/ui/chat/components/__tests__/slash-command-menu.test.ts delete mode 100644 src/ui/chat/constants/wittyPhrases.ts delete mode 100644 src/ui/chat/context/ChatContext.tsx delete mode 100644 src/ui/chat/hooks/__tests__/error-handling.test.ts delete mode 100644 src/ui/chat/hooks/__tests__/useMessageStreaming.test.ts delete mode 100644 src/ui/chat/hooks/index.ts delete mode 100644 src/ui/chat/hooks/types.ts delete mode 100644 src/ui/chat/hooks/useAgentManagement.ts delete mode 100644 src/ui/chat/hooks/useBashExecution.ts delete mode 100644 src/ui/chat/hooks/useChatActions.ts delete mode 100644 src/ui/chat/hooks/useCommandHandler.ts delete mode 100644 src/ui/chat/hooks/useHistoryManagement.ts delete mode 100644 src/ui/chat/hooks/useMessageSending.ts delete mode 100644 src/ui/chat/hooks/useMessageStreaming.ts delete mode 100644 src/ui/chat/hooks/useOverlays.ts delete mode 100644 src/ui/chat/hooks/useSessionPersistence.ts delete mode 100644 src/ui/chat/hooks/useSlashCommands.ts delete mode 100644 src/ui/chat/services/__tests__/session-store.test.ts delete mode 100644 src/ui/components/SessionSelector.tsx diff --git a/README.md b/README.md index a6a39f1..154a102 100644 --- a/README.md +++ b/README.md @@ -1,398 +1,123 @@ # Clix CLI -An interactive AI-powered assistant for Clix SDK development. Built with React and Ink, Clix CLI provides a chat interface with AI agents to help you integrate, debug, and manage the Clix Mobile SDK in your projects. +Command-mode AI assistant for Clix SDK installation and diagnostics. ## Features -- **Interactive Chat Interface**: Natural conversation with AI agents using streaming responses -- **Multiple AI Agents**: Support for Claude, Codex, Gemini, OpenCode, Cursor, and GitHub Copilot -- **Slash Commands**: Quick actions for common tasks and workflows -- **Skills System**: Pre-built workflows for SDK integration, event tracking, user management, and personalization -- **Debug Assistant**: Interactive problem diagnosis and root cause analysis -- **Session Transfer**: Save and continue conversations in native agent CLIs -- **Agent Switching**: Switch between agents mid-conversation with full history preservation -- **Context Management**: Real-time tracking with automatic history compaction at 90% threshold -- **MCP Server Integration**: Built-in installer for Clix MCP Server +- Command-only workflow (no interactive chat mode) +- Multiple AI agents: Claude, Codex, Gemini, OpenCode, Cursor, GitHub Copilot +- Step-based `install` preparation flow for Firebase/iOS push setup +- One-shot prompt execution for `install` and `doctor` +- Built-in MCP installer (`install-mcp`) ## Installation -### Install via npm (Recommended) +### npm ```bash npm install -g @clix-so/clix-cli ``` -### Install via Bun +### Bun ```bash bun add -g @clix-so/clix-cli ``` -### Install via Script +### Script ```bash curl -fsSL https://clix.sh/install | bash ``` -### Install via Homebrew (macOS) +### Homebrew (macOS) ```bash brew tap clix-so/clix-cli brew install clix ``` -### Uninstallation - -See [UNINSTALL.md](UNINSTALL.md) for instructions on how to remove Clix CLI. - ## Prerequisites -- **Node.js 20+** (Bun v1.0+ is optional for faster development) -- **One of the following AI agents**: - -| Agent | Free Plan | CLI Free Usage | -|-------|:---------:|----------------| -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | 1,000 requests/day | -| [GitHub Copilot](https://docs.github.com/copilot/how-tos/set-up/install-copilot-cli) | ✅ | 50 premium requests/month | -| [OpenCode](https://opencode.ai/docs/cli/) | ✅ | Unlimited (with your own API keys) | -| [Cursor](https://cursor.com/cli) | ⚠️ | 50 slow requests/month | -| [Claude Code](https://code.claude.com/docs) | ❌ | Requires Pro ($20/mo) or API | -| [Codex](https://developers.openai.com/codex/cli) | ❌ | Requires ChatGPT Plus ($20/mo) or API | - -> **💡 Tip:** If you don't have an active subscription, we recommend starting with **Gemini CLI** or **GitHub Copilot** which offer generous free tiers. - -## Quick Start - -1. **Start interactive chat**: - -```bash -clix -``` - -2. **Select your AI agent** (if multiple are available): - -```bash -clix agent claude -``` - -3. **Use natural language or slash commands**: +- Node.js 20+ +- At least one supported AI agent installed on your machine +- Vercel Skills installed for your active agent -``` -> How do I integrate Clix SDK into my iOS project? -> /debug -> /integration -``` - -## Commands - -### `clix` (default) - -Start an interactive chat session with your configured AI agent. +Install Vercel Skills: ```bash -clix +npx skills add vercel-labs/agent-skills --agent codex ``` -**Features:** - -- Natural language conversation -- Real-time streaming responses -- Slash commands for quick actions -- Context tracking with usage indicators -- History navigation (↑/↓ arrows) -- Press Escape to cancel streaming - -**Shortcuts:** +Replace `codex` with your agent id (`claude-code`, `gemini-cli`, `opencode`, `cursor`, `github-copilot`, ...). -- `/help` - Show all available commands -- `/debug` - Interactive debugging assistant -- `/install` - Autonomous SDK installation -- `/doctor` - SDK health check -- `/integration` - Interactive SDK integration guide -- `/agent` - Switch AI agents -- `/transfer` - Transfer session to native CLI -- `/exit` - Exit chat - -### `clix agent [name]` - -List available AI agents or switch to a specific agent. +## Quick Start ```bash -# List available agents +clix --help clix agent - -# Switch to Claude -clix agent claude - -# Switch to Codex -clix agent codex -``` - -Detects available agents (Claude, Codex, Gemini, OpenCode, Cursor, GitHub Copilot) on your system. Your selection is saved to `~/.config/clix/config.json`. - -### `clix install` - -Autonomous SDK installation with automatic file modifications. The AI agent will detect your platform, install dependencies, create initialization files, and integrate the SDK without manual intervention. - -```bash clix install - -# Specify target platform -clix install --platform react-native -clix install --platform ios -``` - -**Options:** - -- `--platform ` - Target platform (ios, android, react-native, flutter) - -**iOS Dependency Managers:** - -The install command automatically detects and supports both Swift Package Manager (SPM) and CocoaPods: -- **SPM (Recommended)**: Detected via `Package.swift` or Xcode project with SPM packages -- **CocoaPods**: Detected via `Podfile` - -### `clix doctor` - -Analyze SDK integration status in your project. - -```bash clix doctor ``` -### iOS Push Preparation in `clix install` - -`clix install` runs required iOS push preparation tasks in order: -1. Firebase configuration files -2. APNS key setup in Firebase -3. Firebase Service Account registration -4. iOS entitlements -5. Notification Service Extension (NSE) - -The flow blocks installation until required setup steps are complete. - -### `clix update` - -Check for and install CLI updates. - -```bash -# Check for updates and install with confirmation -clix update - -# Preview update without executing -clix update --dry-run - -# Skip confirmation prompt -clix update --force -``` - -**Options:** - -- `--dry-run` - Preview update without executing -- `--force` - Skip confirmation prompt - -### 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. - -#### `integration` - -SDK integration guide with step-by-step instructions. Unlike `clix install`, this provides interactive guidance for manual integration. - -``` -> clix -> /integration -``` - -#### `event-tracking` +## Commands -Event tracking setup with `Clix.trackEvent()`. +### `clix` (no args) -``` -> clix -> /event-tracking -``` +Shows help and exits. -#### `user-management` +### `clix help` -User management and identification setup. +Shows help and exits. -``` -> clix -> /user-management -``` +### `clix agent [name]` -#### `personalization` +Lists available AI agents or switches the active agent. -Personalization templates setup. +### `clix install [--platform ] [--start-task ]` -``` -> clix -> /personalization -``` +Runs the install pipeline: -#### `api-triggered-campaigns` +1. Preparation UI (step-by-step) +2. One-shot agent execution for SDK integration -API-triggered campaign setup. - -``` -> clix -> /api-triggered-campaigns -``` +Supported platforms: `ios`, `android`, `react-native`, `flutter`. -### `clix debug ` +`--start-task` is development-only and requires `CLIX_DEV_ENABLE_TASK_OVERRIDE=1`. -Interactive debugging assistant for troubleshooting issues. +### `clix doctor [--platform ]` -```bash -clix debug "Push notifications not working on iOS" -``` +Runs one-shot diagnostics and reports SDK integration status. ### `clix install-mcp [agent]` -Install Clix MCP Server for enhanced AI assistance. - -```bash -# Auto-detect agent -clix install-mcp - -# Install for specific agent -clix install-mcp claude -clix install-mcp codex -``` - -## Slash Commands - -Use these commands within the interactive chat (`clix`): - -### Autonomous Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `/install` | | Autonomous SDK installation | -| `/doctor` | | Check SDK integration status | -| `/debug` | | Interactive debugging assistant | - -### Interactive Skills - -| Command | Aliases | Description | -|---------|---------|-------------| -| `/integration` | | SDK integration guide | -| `/event-tracking` | | Event tracking setup | -| `/user-management` | | User management setup | -| `/personalization` | | Personalization templates | -| `/api-triggered-campaigns` | | API-triggered campaign setup | - -### System - -| Command | Aliases | Description | -|---------|---------|-------------| -| `/help` | /?, /h | Show available commands | -| `/new` | /clear | Start a new session | -| `/compact` | /c | Compress conversation history | -| `/agent` | /a | List or switch agents | -| `/transfer` | /t | Transfer to agent CLI | -| `/resume` | | Resume a previous session | -| `/install-mcp` | /mcp | Install Clix MCP Server | -| `/update` | /upgrade | Check for available updates | -| `/exit` | /quit, /q | Exit the chat | - -## Interactive Features +Installs Clix MCP server for the selected/specified agent. -### Debug Assistant +### `clix update [--dry-run] [--force]` -The `/debug` command provides interactive problem diagnosis: +Checks for updates and applies them (or previews with `--dry-run`). -1. Type `/debug` in chat -2. Describe your problem (e.g., "Push notifications not working on iOS") -3. AI investigates your project structure and code -4. Receive root cause analysis and recommended fixes - -``` -> /debug -Describe the problem: Events not appearing in Clix dashboard -[AI explores project, identifies issue, provides fixes] -``` - -### Session Transfer - -Transfer your conversation to continue in the native agent CLI: - -``` -> /transfer claude -✅ Session saved to .clix/sessions/session-1234567890.md - -To continue in Claude Code: -claude "$(cat .clix/sessions/session-1234567890.md)" -``` - -This preserves your entire conversation history and allows you to continue seamlessly in the agent's native interface. - -### Agent Switching - -Switch between agents without losing your conversation: - -``` -> /agent gemini -Switching to Gemini... -[Conversation history preserved] -``` - -### Skills - -Pre-built workflows for common SDK tasks. Skills automatically detect your platform (iOS, Android, React Native, Flutter) and follow Clix SDK best practices. - -- **Autonomous Commands** (`/install`, `/doctor`, `/debug`): Can be run from command-line (`clix install`) or chat mode -- **Interactive Skills** (`/integration`, `/event-tracking`, `/user-management`, `/personalization`, `/api-triggered-campaigns`): Require step-by-step guidance, available only in chat mode - -## Development - -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development guide. - -### Quick Start - -```bash -# Clone the repository -git clone https://github.com/clix-so/clix-cli.git -cd clix-cli - -# Install dependencies -bun install - -# Run in development mode -bun run dev - -# Build -bun run build - -# Compile binaries -bun run build:binary - -# Run tests -bun test -``` +### `clix login` / `clix logout` / `clix whoami` -## Contributing +Account/authentication commands. -Pull requests and issues are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for: +### `clix uninstall [--keep-config] [--keep-state] [--dry-run] [--force]` -- Development setup and workflow -- Code contribution process -- Testing guidelines -- Release process +Uninstalls Clix CLI. -## License +## Install Preparation Order (iOS / push-related) -MIT +`clix install` enforces the following order for required preparation tasks: -## Links +1. Firebase Configuration Files +2. Firebase Service Account +3. APNS Key for Firebase +4. iOS Entitlements +5. Notification Service Extension -- [GitHub Repository](https://github.com/clix-so/clix-cli) -- [Issue Tracker](https://github.com/clix-so/clix-cli/issues) -- [Homebrew Tap](https://github.com/clix-so/homebrew-clix-cli) -- [Clix SDK Documentation](https://clix.so) -- [LLMs.txt](llms.txt) - Detailed documentation for AI assistants +Runtime installation executes only after required preparation steps are complete. ---- +## Notes -Made with love by the Clix team +- `project-build` is internal-only and not exposed as a user command. +- If Vercel Skills is missing for the selected agent, Clix shows install guidance, opens the Skills repository page, and asks you to retry after installation. diff --git a/bun.lock b/bun.lock index bd8f03b..142af7e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "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", "@googleapis/firebase": "^12.0.1", @@ -62,18 +61,12 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], - "@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=="], "@googleapis/firebase": ["@googleapis/firebase@12.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-ODFU7EbB2G/WFsXiNCXjRgJPRxT5JYh3ydznNdsVng/1SnWmQZK8Bgufvxq6ZWnNDqZJTtNUqAwjbMBoP6N3MA=="], - "@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=="], - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@jimp/bmp": ["@jimp/bmp@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "bmp-js": "^0.1.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g=="], @@ -160,7 +153,7 @@ "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=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -192,8 +185,6 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], "bplist-creator": ["bplist-creator@0.1.1", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-Ese7052fdWrxp/vqSJkydgx/1MdBnNOCV2XVfbmdGWD2H6EYza+Q4pyYSuVSnCUD22hfI/BFI4jHaC3NLXLlJQ=="], @@ -222,9 +213,7 @@ "centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], @@ -234,10 +223,6 @@ "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], - "cli-width": ["cli-width@3.0.0", "", {}, "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw=="], - - "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], @@ -272,8 +257,6 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], - "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], - "deffy": ["deffy@2.2.5", "", { "dependencies": { "typpy": "^2.0.0" } }, "sha512-6TX2cfIo97eKqWmqgMDAUulCwnveAe3K+4VGsTGPJsL3NtSEnSBFZ3sUXdS4EBhZ8GbdaZBzXQ04ton18dJrug=="], "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], @@ -292,7 +275,7 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -340,8 +323,6 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -374,15 +355,13 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], "har-schema": ["har-schema@2.0.0", "", {}, "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q=="], "har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-flag": ["has-flag@1.0.0", "", {}, "sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -394,16 +373,12 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ink": ["ink@6.6.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^8.1.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ=="], "ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="], @@ -412,8 +387,6 @@ "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], - "inquirer": ["inquirer@8.2.7", "", { "dependencies": { "@inquirer/external-editor": "^1.0.0", "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^6.0.1" } }, "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA=="], - "is-accessor-descriptor": ["is-accessor-descriptor@1.0.1", "", { "dependencies": { "hasown": "^2.0.0" } }, "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA=="], "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], @@ -434,13 +407,11 @@ "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], - "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], - "is-number": ["is-number@3.0.0", "", { "dependencies": { "kind-of": "^3.0.2" } }, "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg=="], "is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="], - "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], "is-windows": ["is-windows@0.1.1", "", {}, "sha512-3wf9CiLayWrH2O5E99jdTwVZyZwVckl+Gz4CkAtjssBPkawQBoPWDEyAHmwZnODQxqYduCBrlGfKQfvE/Mxh+A=="], @@ -470,8 +441,6 @@ "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], - "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - "jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -488,10 +457,6 @@ "load-bmfont": ["load-bmfont@1.4.2", "", { "dependencies": { "buffer-equal": "0.0.1", "mime": "^1.3.4", "parse-bmfont-ascii": "^1.0.3", "parse-bmfont-binary": "^1.0.5", "parse-bmfont-xml": "^1.1.4", "phin": "^3.7.1", "xhr": "^2.0.1", "xtend": "^4.0.0" } }, "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog=="], - "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - - "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], - "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -522,8 +487,6 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], - "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -542,8 +505,6 @@ "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], - "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], - "os-paths": ["os-paths@7.4.0", "", { "optionalDependencies": { "fsevents": "*" } }, "sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -596,7 +557,7 @@ "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], @@ -612,10 +573,6 @@ "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], - - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -656,18 +613,16 @@ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@3.2.3", "", { "dependencies": { "has-flag": "^1.0.0" } }, "sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A=="], "terminal-char-width": ["terminal-char-width@1.0.11", "", { "dependencies": { "is-windows": "^0.1.1" } }, "sha512-ZYwoONrc9/ptGQsxfOnGYVBbv6892VhcLxOjnfUaaFoOvWZ88Psta6xIqYSsFBE1NUl+g5fOm3klYYxnw9HfJQ=="], - "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], - "timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="], "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], @@ -682,8 +637,6 @@ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], @@ -698,22 +651,16 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -760,122 +707,66 @@ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "ansy/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - "ansy/supports-color": ["supports-color@3.2.3", "", { "dependencies": { "has-flag": "^1.0.0" } }, "sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A=="], - - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], - "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "ink-text-input/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "inquirer/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], - - "inquirer/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - - "inquirer/figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="], - - "inquirer/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "inquirer/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "isomorphic-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - "log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - "pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], - "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "request/qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="], "request/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="], - "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "to-regex-range/is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansy/supports-color/has-flag": ["has-flag@1.0.0", "", {}, "sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA=="], - - "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "inquirer/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - - "inquirer/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - - "inquirer/figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - - "inquirer/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "inquirer/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - - "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - - "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "widest-line/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "inquirer/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "inquirer/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], } } diff --git a/llms.txt b/llms.txt index 1d82427..4b0f80d 100644 --- a/llms.txt +++ b/llms.txt @@ -1,1209 +1,85 @@ # Clix CLI -> Interactive AI-powered assistant for Clix SDK development with chat interface, slash commands, and skills-based workflows +> Command-mode AI assistant for Clix SDK development. -## Overview +## Product Summary -Clix CLI is an interactive command-line tool that provides a chat interface with AI agents to help developers integrate, debug, and manage the Clix Mobile SDK. Built with React and Ink, it offers natural language conversation, streaming responses, and specialized workflows for common SDK tasks. +Clix CLI runs in command mode only. It does not start an interactive chat UI. -**Core Features:** -- Interactive chat interface as the primary interaction mode -- Support for 6 AI agents: Claude, Codex, Gemini, OpenCode, Cursor, and GitHub Copilot -- 20 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 -- Real-time context tracking with automatic compaction +Primary commands: +- `clix install` +- `clix doctor` -## Installation +Each command triggers a one-shot prompt execution against the selected AI agent. -### via npm (Recommended) -```bash -npm install -g @clix-so/clix-cli -``` - -### via Bun -```bash -bun add -g @clix-so/clix-cli -``` - -### via Homebrew (macOS) -```bash -brew tap clix-so/clix-cli -brew install clix -``` - -### via Install Script -```bash -curl -fsSL https://clix.sh/install | bash -``` +## Execution Model -## Prerequisites +1. CLI resolves command and validates flags. +2. `AgentExecutionUI` selects configured agent in command mode. +3. Clix verifies Vercel Skills availability for that agent. +4. Clix executes a single prompt (`oneShot: true`) through the agent executor. -- **Node.js 20 or higher** (Bun v1.0+ is optional for faster development) -- **One of the supported AI agents**: - - [Gemini CLI](https://github.com/google-gemini/gemini-cli) - Google Gemini (Free: 1,000 requests/day) - - [GitHub Copilot](https://docs.github.com/copilot/how-tos/set-up/install-copilot-cli) - GitHub Copilot (Free: 50 premium requests/month) - - [OpenCode](https://opencode.ai/docs/cli/) - Multi-model support (Free: Unlimited with your API keys) - - [Cursor](https://cursor.com/cli) - Multi-model support (50 slow requests/month) - - [Claude Code](https://code.claude.com/docs) - Anthropic's Claude (Requires Pro $20/mo or API) - - [Codex](https://developers.openai.com/codex/cli) - OpenAI's Codex (Requires ChatGPT Plus $20/mo or API) +No chat wrapping, slash command flow, or interactive skill mode is required. -## Commands +## Skills Policy -### `clix` (Primary Command) +- Legacy package-based skill integration is removed from runtime behavior. +- Vercel Skills (`vercel-labs/skills`) is the expected skills mechanism. +- Clix only supports local command prompts (`install`, `doctor`) for execution. +- `project-build` is internal-only and not exposed as a CLI command. -Launches an interactive chat interface with your configured AI agent. This is the main command for interacting with Clix CLI. +Install Vercel Skills example: ```bash -clix +npx skills add vercel-labs/agent-skills --agent codex ``` -**What happens:** -1. Loads configuration from `~/.config/clix/config.json` -2. Detects available AI agents (Claude Code, Codex) -3. Prompts for agent selection if not configured or multiple available -4. Initializes executor with selected agent -5. Opens interactive chat with streaming responses +Use agent-specific ids (`claude-code`, `gemini-cli`, `opencode`, `cursor`, `github-copilot`, ...). -**Features:** -- Natural language conversation with AI -- Real-time streaming responses -- 20 slash commands (type `/` to see menu) -- Context usage tracking (200K token window for Claude Sonnet) -- History navigation with ↑/↓ arrow keys -- Press Escape to cancel streaming requests -- Automatic history compaction at 90% context threshold +## Commands -**Available within chat:** -- All 20 slash commands (4 autonomous + 5 skills + 11 system) -- Natural language queries -- File exploration and code analysis by agent -- Real-time tool execution visibility +### `clix` / `clix help` +Show help and exit. ### `clix agent [name]` +List/switch active AI agent. -List available AI agents or switch to a specific agent. - -```bash -# List available agents -clix agent - -# Switch to a specific agent -clix agent gemini -clix agent claude -``` - -**What it does:** -1. Scans system for available AI agents (gemini, copilot, opencode, agent, claude, codex) -2. Shows interactive selection menu with current agent highlighted -3. Displays installation guidance if no agents found -4. Saves selection to `~/.config/clix/config.json` with timestamp +### `clix install [--platform ] [--start-task ]` +Runs install preparation UI, then one-shot SDK integration execution. -**Configuration stored at:** `~/.config/clix/config.json` -```json -{ - "selectedAgent": "gemini", - "lastUsedAt": "2024-01-08T17:30:00.000Z" -} -``` - -**Recommended:** Start with Gemini (1000 free requests/day) or GitHub Copilot (50 free requests/month) - -### `clix install` +Install preparation task order: +1. Firebase Configuration Files +2. Firebase Service Account +3. APNS Key for Firebase +4. iOS Entitlements +5. Notification Service Extension -Autonomous SDK installation with automatic file modifications. - -```bash -clix install [options] -``` - -**Options:** -- `--platform ` - Target platform (ios, android, react-native, flutter) - -**What it does:** -1. Verifies AI agent is configured (prompts to run `clix agent` if not) -2. Detects platform automatically or uses specified platform -3. Autonomously modifies files (creates, edits) to integrate SDK -4. Runs installation commands (npm install, pod install, etc.) -5. Reports what was done (not what needs to be done manually) - -**Platform support:** iOS, Android, React Native, Flutter - -**Note:** Unlike `/integration` (interactive guidance), this command makes changes autonomously without asking for confirmation. - -### `clix doctor` - -Analyze SDK integration status in your project. - -```bash -clix doctor -``` - -**What it does:** -1. Detects platform automatically -2. Scans for SDK installation -3. Checks push configuration -4. Outputs doctor JSON report with issues and recommendations - -### `clix debug ` - -Interactive debugging assistant for troubleshooting issues. - -```bash -clix debug "Push notifications not working on iOS" -``` - -**What it does:** -1. Takes problem description as argument -2. Investigates project structure and code -3. Provides root cause analysis -4. Gives recommended fixes with file paths - -### `clix update` - -Check for and install CLI updates. - -```bash -# Check for updates and install with confirmation -clix update - -# Preview update without executing -clix update --dry-run - -# Skip confirmation prompt -clix update --force -``` - -**Options:** -- `--dry-run` - Preview update without executing -- `--force` - Skip confirmation prompt - -**What it does:** -1. Checks npm registry for latest version -2. Detects installation method (npm, yarn, pnpm, bun, homebrew, binary) -3. Shows update plan with current and latest version -4. Prompts for confirmation (unless --force) -5. Executes appropriate update command +### `clix doctor [--platform <...>]` +Runs one-shot diagnostics for SDK integration status. ### `clix install-mcp [agent]` +Installs Clix MCP server for target agent. -Install Clix MCP Server for enhanced AI assistance. - -```bash -# Auto-detect agent -clix install-mcp - -# Install for specific agent -clix install-mcp gemini -clix install-mcp claude -``` - -## Interactive Chat - -The interactive chat (`clix` command) is the primary way to interact with Clix CLI. It provides a terminal UI with real-time streaming responses and supports both natural language and slash commands. - -### Chat Interface Features - -**Message Types:** -- **User messages**: Your input -- **Agent messages**: AI responses with streaming -- **System messages**: Status updates, help text -- **Tool messages**: Tool execution indicators - -**Streaming:** -- Responses stream in real-time as agent processes -- Shows tool execution (file reads, searches, etc.) -- Press Escape to cancel streaming -- Partial content preserved on interruption - -**Context Management:** -- Claude Sonnet 4.5: 200,000 token context window -- Real-time usage tracking shown in footer -- Automatic compaction suggestion at 90% (720K characters) -- `/compact` command to compress history -- `/compact force` to bypass confirmation - -**History Navigation:** -- Arrow up (↑) - Previous input -- Arrow down (↓) - Next input -- Disabled when slash command menu is visible - -### Slash Commands - -Type `/` in the chat to see the autocomplete menu. Slash commands are organized into three categories: - -#### Autonomous Commands (3 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) - -#### Interactive Skills (5 commands) - -These execute pre-built workflows from the `@clix-so/clix-agent-skills` package and require step-by-step guidance (available only in chat mode): - -- `/integration` - SDK integration guide with platform detection -- `/event-tracking` - Event tracking setup for analytics -- `/user-management` - User management and identification setup -- `/personalization` - Personalization template creation and debugging -- `/api-triggered-campaigns` - API-triggered campaign setup - -#### System Commands - -These are built-in commands for chat management and tools: - -- `/help` (aliases: `/h`, `/?`) - Display all available commands with descriptions -- `/new` (alias: `/clear`) - Start a new session (clear history) -- `/compact` (alias: `/c`) - Compress conversation history -- `/agent` (alias: `/a`) - List available agents or switch to specific agent -- `/transfer` (alias: `/t`) - Transfer session to agent CLI -- `/resume` - Resume a previous session -- `/install-mcp` (alias: `/mcp`) - Install Clix MCP Server for current agent -- `/update` (alias: `/upgrade`) - Check for available updates -- `/exit` (aliases: `/quit`, `/q`) - Exit the chat - -### Slash Command Autocomplete - -**How it works:** -- Type `/` to enter slash command mode -- Autocomplete menu appears showing matching commands -- Type letters to filter (e.g., `/d` shows debug, doctor) -- Use ↑/↓ arrows to navigate menu -- Press Tab to autocomplete selected command -- Press Enter to execute selected command -- Press Escape to cancel - -**Menu display:** -``` -/install - Autonomous SDK installation [autonomous] -/doctor - Check SDK integration status [autonomous] -/debug - Interactive debugging assistant [autonomous] -/integration - SDK integration guide [skill] -/event-tracking - Event tracking setup [skill] -/user-management - User management setup [skill] -/personalization - Personalization templates [skill] -/api-triggered-campaigns - API-triggered campaign setup [skill] -/help - Show available commands -... -``` - -## Slash Commands Reference - -### Autonomous Commands - -#### `/install` - Autonomous SDK Installation - -**Category:** Autonomous Command - -**What it does:** Automatically installs and integrates the Clix SDK into your mobile project with file modifications. Unlike `/integration`, this command makes changes autonomously without manual steps. - -**Platforms:** iOS, Android, React Native, Flutter - -**Usage:** -```bash -# From command line -clix install -clix install --platform react-native - -# Or in chat -> /install -``` - -**Example:** -``` -> /install -Detecting platform... -✓ React Native project detected -✓ Added @clix-so/react-native-sdk to package.json -✓ Running npm install... -✓ Created initialization module -✓ Updated app/_layout.tsx with SDK initialization -``` - -#### `/doctor` - SDK Integration Analysis - -**Category:** Autonomous Command - -**What it does:** Automatically scans your project to check SDK integration status. Outputs structured JSON report. - -**Checks:** -- Platform detection (iOS/Android/React Native/Flutter) -- SDK installation verification -- Push notification configuration -- Common integration issues - -**Output:** JSON format with findings and recommendations - -**Example:** -``` -> /doctor -Scanning project for SDK integration... -{ - "platform": "ios", - "sdk_installed": true, - "push_configured": false, - "issues": [{"type": "push", "description": "APNs entitlement missing"}] -} -``` - -#### `/debug` - Interactive Debugging Assistant - -**Category:** Autonomous Command - -**What it does:** Opens an interactive prompt asking you to describe your problem, then investigates and provides diagnosis with fixes. - -**Flow:** -1. Type `/debug` -2. Describe your problem (e.g., "Push notifications not working on iOS") -3. Agent investigates project structure and code -4. Provides root cause analysis and recommended fixes - -**Output format:** -- Problem Summary -- Investigation Findings -- Root Cause -- Recommended Fixes (with file paths and code snippets) -- Verification Steps - -**Example:** -``` -> /debug -Describe the problem: Events not appearing in Clix dashboard -[Agent explores SDK initialization, event tracking code] -[Identifies missing SDK.initialize() call] -[Provides fix with exact file location] -``` - -#### iOS Push Preparation in `/install` - -`/install` includes required iOS push setup tasks and blocks progress until each required task is complete. - -**Required order for iOS projects:** -1. Firebase configuration files -2. APNS key setup in Firebase -3. Firebase Service Account registration -4. iOS entitlements -5. Notification Service Extension (NSE) - -**What is automated:** -- Firebase file detection and validation -- Service account JSON import and sender config registration -- Entitlements file updates -- NSE file generation and verification checks - -**What requires manual action (guided by UI):** -- Creating/confirming APNS key in Apple Developer -- Uploading APNS key to Firebase Console -- Final Xcode target/capability confirmation for NSE - -**Entry point:** -``` -> /install -``` - -### Interactive Skills - -#### `/integration` - SDK Integration Guide - -**Category:** Interactive Skill (Chat Mode Only) - -**What it does:** Provides step-by-step guidance for manually integrating the Clix SDK. Unlike `/install`, this provides interactive guidance rather than making changes autonomously. - -**Platforms:** iOS, Android, React Native, Flutter - -**Example:** -``` -> /integration -[Agent detects iOS project] -[Provides step-by-step Podfile updates, SDK initialization guidance] -``` - -#### `/event-tracking` - Event Tracking Setup - -**Category:** Interactive Skill (Chat Mode Only) - -**What it does:** Sets up event tracking with `Clix.trackEvent()` for analytics and user behavior monitoring with guided workflow. - -**Platforms:** iOS, Android, React Native, Flutter - -**Example:** -``` -> /event-tracking -[Agent helps implement event tracking] -[Shows code examples for custom events] -[Creates event plan and validation] -``` - -#### `/user-management` - User Management Setup - -**Category:** Interactive Skill (Chat Mode Only) - -**What it does:** Configures user identification and properties management with Clix SDK through guided steps. - -**Example:** -``` -> /user-management -[Agent guides through setUserId(), setUserProperties()] -[Explains best practices for user identification and logout handling] -``` - -#### `/personalization` - Personalization Templates - -**Category:** Interactive Skill (Chat Mode Only) - -**What it does:** Creates and debugs personalization templates for push notifications and in-app messages using Liquid-style templates. - -**Example:** -``` -> /personalization -[Agent helps create templates with variables] -[Debugs template rendering issues] -[Validates template syntax] -``` - -#### `/api-triggered-campaigns` - API-Triggered Campaign Setup - -**Category:** Interactive Skill (Chat Mode Only) - -**What it does:** Helps configure API-triggered campaigns in the Clix console and implement backend trigger code. - -**Example:** -``` -> /api-triggered-campaigns -[Agent guides through campaign configuration] -[Provides backend integration patterns] -[Creates API trigger plan] -``` - -### System Commands - -#### `/help` - Show Commands - -**Category:** System Command - -**Aliases:** `/h`, `/?` - -**What it does:** Displays all available slash commands organized by category. - -**Example:** -``` -> /help -Available Commands: - -Autonomous Commands: - /install - Autonomous SDK installation - /doctor - Check SDK integration status - /debug - Interactive debugging assistant - -Interactive Skills: - /integration - SDK integration guide - /event-tracking - Event tracking setup - /user-management - User management setup - /personalization - Personalization templates - /api-triggered-campaigns - API-triggered campaign setup - -System Commands: - /help - Show available commands - /new - Start a new session - /compact - Compress conversation history - /agent - List or switch agents - /transfer - Transfer to agent CLI - /resume - Resume a previous session - /install-mcp - Install Clix MCP Server - /update - Check for available updates - /exit - Exit the chat -``` - -#### `/new` - Start New Session - -**Category:** System Command - -**Aliases:** `/clear` - -**What it does:** Clears entire chat history and starts a new session. This resets executor state. - -**Warning:** This action cannot be undone. - -**Example:** -``` -> /new -✓ New session started -``` - -#### `/compact` - Compress History - -**Category:** System Command - -**Aliases:** `/c` - -**What it does:** Compresses conversation history by summarizing old messages while preserving recent context. - -**Compaction strategy:** -- Threshold: 90% of 200K token context (720K characters) -- Preserves last 30% of conversation -- Summarizes older 70% using AI -- Creates new history: [summary] + [preserved messages] - -**Example:** -``` -> /compact -Context usage: 92% (730,000 / 800,000 characters) -Compressing conversation history... -✓ Compacted from 45 messages to 12 messages -Reduced size by 68% -``` - -#### `/agent` - Agent Management - -**Category:** System Command - -**Aliases:** `/a` - -**What it does:** Lists available agents or switches to a specific agent. - -**Syntax:** -- `/agent` - Lists all available agents with current selection -- `/agent claude` - Switches to Claude -- `/agent gemini` - Switches to Gemini -- `/agent copilot` - Switches to GitHub Copilot - -**History preservation:** Full conversation history is transferred when switching agents. - -**Example:** -``` -> /agent -Available agents: - Claude - Gemini (current) - Copilot - OpenCode - Cursor - -> /agent claude -Switching to Claude... -✓ Agent switched. Conversation history preserved. -``` - -#### `/transfer` - Session Transfer - -**Category:** System Command - -**Aliases:** `/t` - -**What it does:** Saves current conversation to a file and provides command to continue in the native agent CLI. - -**Syntax:** -- `/transfer` - Shows interactive selector -- `/transfer claude` - Transfers to Claude Code -- `/transfer gemini` - Transfers to Gemini - -**Process:** -1. Conversation history saved to `.clix/sessions/session-{timestamp}.md` -2. Markdown formatted with full history -3. Command generated to continue in agent CLI -4. User exits and runs provided command - -**Example:** -``` -> /transfer claude -✅ Session saved! - -File: .clix/sessions/session-1704735000000.md - -To continue in Claude Code: - -claude "$(cat .clix/sessions/session-1704735000000.md)" -``` - -#### `/resume` - Resume Session - -**Category:** System Command - -**What it does:** Resumes a previously saved session from the sessions directory. - -**Example:** -``` -> /resume -Select a session to resume: - session-1704735000000.md (2024-01-08 17:30:00) - session-1704734000000.md (2024-01-08 16:00:00) -[Select and load previous conversation] -``` - -#### `/install-mcp` - Install MCP Server - -**Category:** System Command - -**Aliases:** `/mcp` - -**What it does:** Installs the Clix MCP Server for your configured agent to provide enhanced context and capabilities. - -**Supported agents:** Claude, Codex, Gemini, OpenCode, Cursor, Copilot - -**Example:** -``` -> /install-mcp -Select agent to install MCP server: -❯ Claude - Gemini - -✅ Clix MCP Server installed for Claude. -Restart Claude to activate. -``` - -#### `/update` - Check for Updates - -**Category:** System Command - -**Aliases:** `/upgrade` - -**What it does:** In interactive mode, displays CLI update instructions. For actual updates, use `clix update` from the terminal with options like `--dry-run` and `--force`. - -**Example:** -``` -> /update -To update Clix CLI, run `clix update` from the terminal. - -Available options: - --dry-run Preview update without executing - --force Skip confirmation prompt -``` - -#### `/exit` - Exit Chat - -**Category:** System Command - -**What it does:** Exits the interactive chat and returns to terminal. - -**Example:** -``` -> /exit -Goodbye! -``` - -## Agent System - -### Supported Agents - -Clix CLI supports 6 AI agents. Only one can be active at a time, but you can switch between them with full history preservation. - -#### Gemini (Google) - -**Command:** `gemini` -**Package:** `@google/gemini-cli` -**Free Tier:** 1,000 requests/day -**Install:** https://github.com/google-gemini/gemini-cli - -**Features:** -- Fast responses -- Generous free tier -- Multi-turn conversations -- Tool execution support - -#### GitHub Copilot - -**Command:** `copilot` -**Package:** `@github/copilot` -**Free Tier:** 50 premium requests/month -**Install:** https://docs.github.com/copilot/how-tos/set-up/install-copilot-cli - -**Features:** -- Multiple models available -- MCP support -- Tool execution -- GitHub integration - -#### OpenCode - -**Command:** `opencode` -**Package:** `opencode-cli` -**Free Tier:** Unlimited (with your API keys) -**Install:** https://opencode.ai/docs/cli/ - -**Features:** -- Multi-model support -- Bring your own API keys -- Tool execution -- Flexible configuration - -#### Cursor - -**Command:** `agent` -**Package:** `cursor-agent` -**Free Tier:** 50 slow requests/month -**Install:** https://cursor.com/cli - -**Features:** -- Multi-model support -- Tool execution -- IDE integration - -#### Claude (Anthropic) - -**Command:** `claude` -**Package:** `@anthropic-ai/claude-agent-sdk` -**Model:** Claude 3.5 Sonnet -**Pricing:** Pro ($20/mo) or API -**Install:** https://code.claude.com/docs - -**Features:** -- Session-based conversations -- Streaming responses -- Tool execution support -- Context window: 200,000 tokens - -**Executor:** `ClaudeExecutor` (src/lib/executors/claude-executor.ts) - -#### Codex (OpenAI) - -**Command:** `codex` -**Package:** `@openai/codex-sdk` -**Model:** GPT-4 -**Pricing:** ChatGPT Plus ($20/mo) or API -**Install:** https://developers.openai.com/codex/cli - -**Features:** -- Thread-based conversations -- Tool execution support -- Persistent threads across messages - -**Executor:** `CodexExecutor` (src/lib/executors/codex-executor.ts) - -### Agent Detection - -**How it works:** -- Uses `which` command to check if agent executables are installed -- Tests all 6 agent commands: `claude`, `codex`, `gemini`, `opencode`, `agent` (Cursor), `copilot` -- Returns array of available agents - -**Location:** `src/lib/agents.ts` - `detectAvailableAgents()` - -### Agent Configuration - -**Config file:** `~/.config/clix/config.json` (or `$XDG_CONFIG_HOME/clix/config.json`) - -```json -{ - "selectedAgent": "claude", - "lastUsedAt": "2024-01-08T17:30:00.000Z" -} -``` - -**Management:** `src/lib/config.ts` - `ConfigManager` -- `load()` - Loads config from disk -- `save()` - Persists config to disk -- Migration support for old `selectedCLI` field - -### Agent Switching - -**Command:** `/agent ` - -**Process:** -1. Validates target agent is available -2. Gets conversation history from current executor -3. Creates new executor for target agent -4. Transfers history to new executor -5. Resets session/thread state - -**History preservation:** Full conversation history is maintained when switching. - -### Session Management - -**Executors maintain:** -- Conversation history (array of `ConversationMessage`) -- Session/thread state -- Context window tracking - -**History compaction:** -- Uses `HistoryCompactionService` -- Threshold: 90% of context window -- Preserves recent 30% of conversation -- Summarizes older 70% using the executor itself - -## Usage Scenarios - -### Scenario 1: First-Time Setup - -```bash -# Install globally -npm install -g @clix-so/clix-cli - -# Select AI agent (we recommend Gemini for free tier) -clix agent -# Shows available agents, select from menu - -# Start interactive chat -clix -> How do I integrate Clix SDK into my iOS project? -[Agent provides step-by-step guidance] -``` - -### Scenario 2: Autonomous SDK Installation - -```bash -clix install -Detecting platform... -✓ React Native project detected -✓ Added @clix-so/react-native-sdk to package.json -✓ Running npm install... -✓ Created initialization module -✓ Updated app/_layout.tsx with SDK initialization -Installation complete! Replace placeholder credentials in .env -``` - -### Scenario 3: Interactive SDK Integration - -```bash -clix -> /integration -[Agent detects iOS project] -[Guides through Podfile, initialization, configuration] -> Show me example code for AppDelegate -[Agent provides Swift code example] -``` - -### Scenario 4: Debugging Issues - -```bash -clix -> /debug -Describe the problem: Push notifications not working on iOS -[Agent investigates project] -[Identifies missing APNs entitlement] -[Provides fix with exact steps] -``` - -### Scenario 5: Event Tracking Setup - -```bash -clix -> /event-tracking -[Agent helps implement event tracking] -[Shows how to use Clix.trackEvent()] -[Creates event plan and validates schema] -``` - -### Scenario 6: Switching Agents - -```bash -clix -[Currently using Gemini] -> /agent claude -Switching to Claude... -✓ Agent switched. History preserved. -[Now using Claude with same conversation] -``` - -### Scenario 7: Session Transfer - -```bash -clix -[Having a long conversation] -> /transfer claude -✅ Session saved to .clix/sessions/session-1704735000.md - -To continue in Claude Code: -claude "$(cat .clix/sessions/session-1704735000.md)" - -# Exit and run provided command -$ claude "$(cat .clix/sessions/session-1704735000.md)" -[Conversation continues in native Claude CLI] -``` - -### Scenario 8: Managing Context - -```bash -clix -[Context: 92% used] -> /compact -Compressing conversation history... -✓ Compacted from 45 to 12 messages -Context: 45% used -[Continue conversation with more space] -``` - -## Architecture - -### Chat System - -**UI Framework:** React + Ink (terminal UI components) - -**Components:** -- `ChatApp.tsx` - Main chat application -- `ChatInput.tsx` - Input with slash command autocomplete -- `SlashCommandMenu.tsx` - Command menu display -- `MessageList.tsx` - Message history rendering -- `DebugPrompt.tsx` - Interactive debug modal -- `AgentSelector.tsx` - Agent selection UI -- `TransferSelector.tsx` - Transfer target selection -- `MCPInstallSelector.tsx` - MCP installation UI - -**State Management:** -- `ChatContext.tsx` - React context for chat state -- `useChatActions.ts` - Chat actions and executor management - -### Executor System - -**Interface:** `AgentExecutor` (src/lib/executor.ts) - -```typescript -interface AgentExecutor { - name: string; - execute(prompt: string, options?: ExecuteOptions): AsyncGenerator; - isAvailable(): Promise; - clearHistory(): void; - getHistory(): ConversationMessage[]; - setHistory(history: ConversationMessage[]): void; - needsCompaction(): boolean; - compactHistory(force?: boolean): Promise; -} -``` - -**Implementations:** -- `ClaudeExecutor` - Claude Code integration -- `CodexExecutor` - Codex integration - -**Message flow:** -- User input → executor.execute() → AsyncGenerator -- Yields: text, tool_call, tool_result, error, complete -- UI updates in real-time as messages stream - -### Services - -**Debug Service** (src/lib/services/debug-service.ts) -- Generates debug prompts with problem description -- Includes project path and investigation instructions -- Formats output structure for agent - -**Transfer Service** (src/lib/services/transfer-service.ts) -- Saves conversation history to markdown -- Generates command for native CLI continuation -- Checks target CLI availability - -**MCP Install Service** (src/lib/services/mcp-install-service.ts) -- Installs Clix MCP Server for agents -- Claude: Uses `claude mcp add` command -- Codex: Updates `~/.codex/config.toml` - -**History Compaction Service** (src/lib/services/history-compaction.ts) -- Compresses conversation history at 90% threshold -- Preserves recent 30% of messages -- Summarizes older messages using executor -- Reduces context usage by ~70% - -## Configuration - -### User Config - -**Location:** `~/.config/clix/config.json` (or `$XDG_CONFIG_HOME/clix/config.json`) - -```json -{ - "selectedAgent": "gemini", - "lastUsedAt": "2024-01-08T17:30:00.000Z" -} -``` - -**Fields:** -- `selectedAgent`: Name of currently selected agent (claude, codex, gemini, opencode, cursor, copilot) -- `lastUsedAt`: ISO timestamp of last usage - -**Management:** `src/lib/config.ts` - `ConfigManager` -- Uses XDG base directory specification -- Migration support for old config paths -- Automatic directory creation - -### Session Files - -**Location:** `.clix/sessions/` (project-local) - -**Format:** Markdown with conversation history - -```markdown -# Clix CLI Session - -_Saved: 2024-01-08T17:30:00.000Z_ - ---- - -## 👤 User -[User message content] - -## 🤖 Assistant -[Agent response content] - ---- - -Please review the conversation history above and continue from where we left off. -``` - -**Usage:** Transfer sessions to native agent CLIs - -**File naming:** `session-{timestamp}.md` where timestamp is Unix milliseconds - -### MCP Configuration - -Clix MCP Server can be installed for all supported agents using the `/install-mcp` command. - -**Configuration locations:** -- **Claude:** Uses `claude mcp add` command -- **Gemini:** Agent-specific config -- **Copilot:** Agent-specific config -- **OpenCode:** Agent-specific config -- **Cursor:** Agent-specific config -- **Codex:** `~/.codex/config.toml` - -**Example (Codex):** -```toml -[mcp_servers] - [mcp_servers.clix] - command = "npx" - args = ["-y", "@clix-so/clix-mcp-server@latest"] -``` - -## Common Issues and Solutions - -### Issue: "No AI agent configured" - -**Cause:** First time running without configuration - -**Solution:** -```bash -clix agent -# Select agent from menu - we recommend starting with Gemini (free, 1000 requests/day) -``` - -### Issue: "Agent not found: " - -**Cause:** Configured agent is not installed or not in PATH - -**Solution:** -```bash -# Install one of the supported agents: -# - Gemini CLI: https://github.com/google-gemini/gemini-cli (recommended for free tier) -# - GitHub Copilot: https://docs.github.com/copilot/how-tos/set-up/install-copilot-cli -# - OpenCode: https://opencode.ai/docs/cli/ -# - Cursor: https://cursor.com/cli -# - Claude: https://code.claude.com/docs -# - Codex: https://developers.openai.com/codex/cli - -# Or select a different available agent -clix agent -``` - -### Issue: Context limit approaching - -**Cause:** Long conversation exceeding 90% of context window - -**Solution:** -```bash -# In chat: -> /compact -# Or force compaction: -> /compact force -``` - -### Issue: Session transfer not working - -**Cause:** Target agent CLI not installed or not in PATH - -**Solution:** -```bash -# Check if agent is available -which claude # or which codex - -# Install if missing -# Then try transfer again -``` - -## Best Practices - -1. **Start with agent selection** - ```bash - clix agent # Select agent before first use (Gemini or Copilot recommended for free tier) - ``` - -2. **Use slash commands for quick actions** - ``` - /install # Autonomous SDK installation - /integration # Interactive integration guide - /debug # For problem diagnosis - /doctor # For automated SDK status check - /event-tracking # Event tracking setup - /user-management # User identification setup - ``` - -3. **Leverage autonomous commands** - ``` - # Autonomous SDK installation (makes changes directly) - clix install - - # Interactive debugging - /debug - Describe: Specific problem you're facing - [Get targeted investigation and fixes] - ``` - -4. **Transfer long sessions** - ``` - # When conversation gets complex: - /transfer claude - [Continue in native CLI with full context] - ``` - -5. **Manage context proactively** - ``` - # At 90% usage: - /compact - [Free up space while preserving recent context] - ``` - -6. **Switch agents when needed** - ``` - # Try different agent for fresh perspective: - /agent gemini - [Same conversation, different agent approach] - ``` - -## For AI Assistants +### `clix update [--dry-run] [--force]` +Checks and optionally applies CLI updates. -When helping users with Clix CLI, keep these points in mind: +### `clix login` / `clix logout` / `clix whoami` / `clix uninstall` +Authentication and lifecycle commands. -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. **Slash commands** - Autonomous commands + interactive skills + system commands -5. **Autonomous vs Interactive** - Autonomous commands (`/install`, `/doctor`, `/debug`) 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 `.clix/sessions/`, provides command for native CLI -9. **Agent switching** - Preserves history when switching between any agents -10. **Context management** - 200K tokens (Claude), auto-compact at 90% -11. **MCP integration** - Built-in installer for Clix MCP Server supporting all agents -12. **Storage paths** - Global config in `~/.config/clix/`, project-local data in `.clix/` (sessions, credentials) +## Architecture Notes -**When users ask about:** -- "How to use" → Emphasize `clix` command starts chat, use `/help` for commands -- "Which agents" → 6 agents supported: Gemini (free, 1000/day), Copilot (free, 50/mo), OpenCode, Cursor, Claude, Codex - configure with `clix agent` -- "Free tier" → Recommend Gemini (1000 requests/day) or GitHub Copilot (50 premium/month) -- "Installation" → `/install` for autonomous, `/integration` for guided steps -- "Debugging" → Recommend `/debug` for interactive diagnosis -- "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 `/install` to run required iOS push setup tasks (Firebase files, APNS, service account, entitlements, NSE) -- "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) -- "API campaigns" → Use `/api-triggered-campaigns` skill (backend integration) -- "Context full" → Suggest `/compact` or `/c` to compress history -- "Transfer session" → Use `/transfer` or `/t` to continue in native CLI -- "Start fresh" → Use `/new` to start a new session (not `/clear`, which is legacy alias) +- Entry point: `src/cli.tsx` +- Install command wrapper: `src/commands/install.tsx` +- Doctor command wrapper: `src/commands/doctor.tsx` +- Command prompt executor for install/doctor: `src/commands/skill/index.tsx` +- Prompt composition: `src/lib/skills.ts` +- Vercel Skills checks: `src/lib/services/vercel-skills.ts` +- Command execution UI: `src/ui/AgentExecutionUI.tsx` +- Install preparation UI: `src/ui/components/InstallPreparationUI.tsx` -## Keywords +## Behavioral Guarantees -CLI, command-line, interactive chat, mobile SDK, iOS, Android, React Native, Flutter, AI-powered, automation, Gemini, Claude, Codex, GitHub Copilot, OpenCode, Cursor, Anthropic, OpenAI, Google, slash commands, autonomous installation, interactive skills, debug assistant, session transfer, context management, streaming responses, terminal UI, React, Ink, push notifications, SDK integration, developer tools, skills system, agent switching, MCP, Model Context Protocol, XDG, event tracking, user management, personalization, API-triggered campaigns, Swift Package Manager, SPM, CocoaPods, Xcode +- Command mode only. +- One-shot execution for agent-triggered commands. +- Install runtime task success is based on actual execution results, not stubs. +- Firebase service account and config mismatch is surfaced as mismatch (not completed). diff --git a/package.json b/package.json index 9dcf980..d94bef1 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "scripts": { "dev": "bun run src/cli.tsx", "build": "bun run scripts/build.ts", + "build:local": "bun run build", "build:binary": "bun run scripts/compile.ts", "build:binary:checksums": "bun run scripts/compile.ts --checksums", "install:local": "bun run scripts/install-local.ts", @@ -46,7 +47,6 @@ }, "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", "@googleapis/firebase": "^12.0.1", diff --git a/scripts/build.ts b/scripts/build.ts index 2f82c07..0e856d9 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -5,11 +5,11 @@ import { mkdir, readFile, rm } from 'node:fs/promises'; const DIST_DIR = './dist'; /** - * Embed skills from @clix-so/clix-agent-skills package. - * This must run before building to ensure skills are available in the binary. + * Embed local command prompts. + * This must run before building to keep binary builds self-contained. */ async function embedSkills() { - console.log('Embedding skills from @clix-so/clix-agent-skills...'); + console.log('Embedding local command prompts...'); const proc = Bun.spawn(['bun', 'scripts/embed-skills.ts'], { stdout: 'inherit', stderr: 'inherit', @@ -23,7 +23,7 @@ async function embedSkills() { async function build() { console.log('Building clix-cli...'); - // Embed skills before building (required for binary to work standalone) + // Embed local command prompts before building (required for binary standalone use) await embedSkills(); console.log(''); @@ -47,7 +47,6 @@ async function build() { // that need to be resolved at runtime from node_modules '@anthropic-ai/claude-agent-sdk', '@openai/codex-sdk', - // Note: @clix-so/clix-agent-skills is embedded at build time via embed-skills.ts // External optional dependencies that shouldn't be bundled 'react-devtools-core', 'ws', diff --git a/scripts/embed-skills.ts b/scripts/embed-skills.ts index d0a918e..f4e7ad6 100644 --- a/scripts/embed-skills.ts +++ b/scripts/embed-skills.ts @@ -1,392 +1,136 @@ #!/usr/bin/env bun /** - * Embed skills from @clix-so/clix-agent-skills package into the build. - * This allows the binary to work without requiring the skills package to be installed. + * Embed local command prompts into src/lib/embedded-skills.ts. * - * Skills are discovered dynamically by scanning the skills folder and parsing - * YAML frontmatter from SKILL.md files. + * This script intentionally embeds only local prompts (install/doctor), + * not external package-based skills. */ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; const OUTPUT_FILE = './src/lib/embedded-skills.ts'; +const LOCAL_SKILLS_DIR = './src/lib/skills'; -/** - * Skills to exclude from embedding. - * These are internal/developer tools not meant for end users. - */ -const EXCLUDED_SKILLS = new Set(['skill-creator']); - -/** - * Skill metadata parsed from SKILL.md frontmatter. - */ -interface SkillMetadata { - /** Skill folder name (e.g., 'integration', 'event-tracking') */ - folder: string; - /** Skill name from frontmatter (e.g., 'clix-integration') */ - name: string; - /** Command name derived from name by removing 'clix-' prefix */ - commandName: string; - /** Display name from frontmatter (e.g., 'SDK Integration') */ - displayName: string; - /** Short description for command menus */ - shortDescription: string; - /** Full description from frontmatter (for agent invocation) */ - description: string; - /** Whether the skill can be invoked by users */ - userInvocable: boolean; -} - -/** - * Derive command name from skill name by removing 'clix-' prefix. - */ -function deriveCommandName(skillName: string): string { - return skillName.replace(/^clix-/, ''); -} - -function getSkillsPackagePath(): string | null { - try { - const require = createRequire(import.meta.url); - const packagePath = require.resolve('@clix-so/clix-agent-skills/package.json'); - return dirname(packagePath); - } catch { - return null; - } -} - -/** - * Discover all skill folders in the skills directory. - */ -function discoverSkillFolders(packagePath: string): string[] { - const skillsDir = join(packagePath, 'skills'); - if (!existsSync(skillsDir)) { +function discoverLocalSkillFolders(): string[] { + if (!existsSync(LOCAL_SKILLS_DIR)) { return []; } - return readdirSync(skillsDir).filter((entry) => { - // Skip excluded skills - if (EXCLUDED_SKILLS.has(entry)) { - return false; - } - const entryPath = join(skillsDir, entry); + return readdirSync(LOCAL_SKILLS_DIR).filter((entry) => { + const entryPath = join(LOCAL_SKILLS_DIR, entry); const skillMdPath = join(entryPath, 'SKILL.md'); return statSync(entryPath).isDirectory() && existsSync(skillMdPath); }); } -/** - * Parse YAML frontmatter from SKILL.md content. - */ -function parseFrontmatter(content: string): Record { - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); - if (!frontmatterMatch) { - return {}; - } - - const frontmatter: Record = {}; - const lines = frontmatterMatch[1].split('\n'); - let currentKey = ''; - let currentValue = ''; - - for (const line of lines) { - // Check if line starts a new key - const keyMatch = line.match(/^([a-zA-Z-]+):\s*(.*)$/); - if (keyMatch) { - // Save previous key-value if exists - if (currentKey) { - frontmatter[currentKey] = parseValue(currentValue.trim()); - } - currentKey = keyMatch[1]; - currentValue = keyMatch[2]; - } else if (currentKey && line.startsWith(' ')) { - // Multi-line value continuation - currentValue += ` ${line.trim()}`; - } - } - - // Save last key-value - if (currentKey) { - frontmatter[currentKey] = parseValue(currentValue.trim()); - } - - return frontmatter; -} - -/** - * Parse a YAML value to appropriate type. - */ -function parseValue(value: string): string | boolean { - if (value === 'true') return true; - if (value === 'false') return false; - return value; -} - -/** - * Read and parse a skill from its folder. - */ -function readSkill( - packagePath: string, - folder: string, -): { content: string; metadata: SkillMetadata } | null { - const skillPath = join(packagePath, 'skills', folder, 'SKILL.md'); +function readLocalSkill(folder: string): string | null { + const skillPath = join(LOCAL_SKILLS_DIR, folder, 'SKILL.md'); if (!existsSync(skillPath)) { - console.warn(`Warning: SKILL.md not found in ${folder}`); return null; } - - const content = readFileSync(skillPath, 'utf-8'); - const frontmatter = parseFrontmatter(content); - - // Check if skill is user-invocable (default to true if not specified) - const userInvocable = frontmatter['user-invocable']; - const isUserInvocable = - userInvocable === true || userInvocable === undefined || userInvocable === ''; - - // Skip skills that are explicitly not user-invocable - if (userInvocable === false) { - console.log(` Skipped: ${folder} (not user-invocable)`); - return null; - } - - const name = (frontmatter.name as string) || `clix-${folder}`; - const metadata: SkillMetadata = { - folder, - name, - commandName: deriveCommandName(name), - displayName: (frontmatter['display-name'] as string) || formatDisplayName(folder), - shortDescription: (frontmatter['short-description'] as string) || '', - description: (frontmatter.description as string) || '', - userInvocable: isUserInvocable, - }; - - return { content, metadata }; -} - -/** - * Format a folder name as a display name. - */ -function formatDisplayName(folder: string): string { - return folder - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); + return readFileSync(skillPath, 'utf-8'); } function escapeTemplateString(str: string): string { return str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); } -function needsQuoting(key: string): boolean { - return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key); -} - -function generateEmbeddedSkillsFile( - skills: Record, - metadata: SkillMetadata[], -): string { +function generateEmbeddedSkillsFile(skills: Record): string { const skillEntries = Object.entries(skills) - .map(([key, content]) => { - const formattedKey = needsQuoting(key) ? `'${key}'` : key; - return ` ${formattedKey}: \`${escapeTemplateString(content)}\`,`; - }) - .join('\n'); - - const metadataEntries = metadata - .map( - (m) => ` { - folder: '${m.folder}', - name: '${m.name}', - commandName: '${m.commandName}', - displayName: '${m.displayName}', - shortDescription: '${escapeString(m.shortDescription)}', - description: - '${escapeString(m.description)}', - userInvocable: ${m.userInvocable}, - },`, - ) + .map(([key, content]) => ` '${key}': \`${escapeTemplateString(content)}\`,`) .join('\n'); return `/** - * Auto-generated file containing embedded skill prompts and metadata. + * Auto-generated file containing embedded command prompts. * DO NOT EDIT MANUALLY - regenerate with: bun scripts/embed-skills.ts * - * These are embedded at build time from @clix-so/clix-agent-skills package - * to allow the binary to work without requiring the package to be installed. + * Only local command prompts are embedded. */ /** - * Skill metadata interface. + * Skill metadata interface kept for backward compatibility. */ export interface SkillMetadata { - /** Skill folder name (e.g., 'integration', 'event-tracking') */ folder: string; - /** Skill name from frontmatter (e.g., 'clix-integration') */ name: string; - /** Command name derived from name by removing 'clix-' prefix */ commandName: string; - /** Display name from frontmatter (e.g., 'SDK Integration') */ displayName: string; - /** Short description for command menus */ shortDescription: string; - /** Full description from frontmatter (for agent invocation) */ description: string; - /** Whether the skill can be invoked by users */ userInvocable: boolean; } /** - * Embedded skill content by folder name. + * Embedded prompt content by folder name. */ export const EMBEDDED_SKILLS: Record = { ${skillEntries} }; /** - * Embedded skill metadata. + * Package-based metadata is intentionally empty. */ -export const EMBEDDED_SKILL_METADATA: SkillMetadata[] = [ -${metadataEntries} -]; +export const EMBEDDED_SKILL_METADATA: SkillMetadata[] = []; -/** - * Check if embedded skills are available. - */ export function hasEmbeddedSkills(): boolean { return Object.keys(EMBEDDED_SKILLS).length > 0; } -/** - * Get an embedded skill by folder name. - */ export function getEmbeddedSkill(skillFolder: string): string | undefined { return EMBEDDED_SKILLS[skillFolder]; } -/** - * Get all embedded skill folder names. - */ export function getEmbeddedSkillFolders(): string[] { return Object.keys(EMBEDDED_SKILLS); } -/** - * Get metadata for all embedded skills. - */ export function getEmbeddedSkillMetadata(): SkillMetadata[] { return EMBEDDED_SKILL_METADATA; } -/** - * Get metadata for a specific skill by command name. - */ -export function getSkillMetadataByCommand(commandName: string): SkillMetadata | undefined { - return EMBEDDED_SKILL_METADATA.find((m) => m.commandName === commandName); +export function getSkillMetadataByCommand(_commandName: string): SkillMetadata | undefined { + return undefined; } -/** - * Get metadata for a specific skill by folder name. - */ -export function getSkillMetadataByFolder(folder: string): SkillMetadata | undefined { - return EMBEDDED_SKILL_METADATA.find((m) => m.folder === folder); +export function getSkillMetadataByFolder(_folder: string): SkillMetadata | undefined { + return undefined; } `; } -function escapeString(str: string): string { - return str.replace(/'/g, "\\'").replace(/\n/g, ' '); -} - -/** - * Discover local skill folders in src/lib/skills directory. - */ -function discoverLocalSkillFolders(): string[] { - const localSkillsDir = './src/lib/skills'; - if (!existsSync(localSkillsDir)) { - return []; - } - - return readdirSync(localSkillsDir).filter((entry) => { - const entryPath = join(localSkillsDir, entry); - const skillMdPath = join(entryPath, 'SKILL.md'); - return statSync(entryPath).isDirectory() && existsSync(skillMdPath); - }); -} - -/** - * Read a local skill from src/lib/skills directory. - */ -function readLocalSkill(folder: string): { content: string; folder: string } | null { - const skillPath = join('./src/lib/skills', folder, 'SKILL.md'); - if (!existsSync(skillPath)) { - console.warn(`Warning: SKILL.md not found in ${folder}`); - return null; - } - - const content = readFileSync(skillPath, 'utf-8'); - return { content, folder }; -} - async function main() { - console.log('Embedding skills from @clix-so/clix-agent-skills...'); + console.log('Embedding local command prompts...'); - const packagePath = getSkillsPackagePath(); - if (!packagePath) { - console.error('ERROR: Skills package not found. Cannot embed skills.'); - console.error('Please install the package: npm install @clix-so/clix-agent-skills'); + const localFolders = discoverLocalSkillFolders(); + if (localFolders.length === 0) { + console.error('No local skill folders found under src/lib/skills'); process.exit(1); } - console.log(`Found skills package at: ${packagePath}`); - - // Discover all skill folders - const folders = discoverSkillFolders(packagePath); - console.log(`Discovered ${folders.length} skill folders: ${folders.join(', ')}`); - const skills: Record = {}; - const metadata: SkillMetadata[] = []; - for (const folder of folders) { - const skill = readSkill(packagePath, folder); - if (skill) { - skills[folder] = skill.content; - metadata.push(skill.metadata); - console.log(` Embedded: ${skill.metadata.commandName} (${folder})`); + for (const folder of localFolders) { + const content = readLocalSkill(folder); + if (!content) { + console.warn(`Skipped local skill folder without SKILL.md: ${folder}`); + continue; } - } - // Also embed local skills from src/lib/skills - console.log('\nEmbedding local skills from src/lib/skills...'); - const localFolders = discoverLocalSkillFolders(); - if (localFolders.length > 0) { - console.log( - `Discovered ${localFolders.length} local skill folders: ${localFolders.join(', ')}`, - ); - for (const folder of localFolders) { - const localSkill = readLocalSkill(folder); - if (localSkill) { - // Prefix local skills with 'local-' to avoid name conflicts - skills[`local-${folder}`] = localSkill.content; - console.log(` Embedded: ${folder} (local)`); - } - } + const key = `local-${folder}`; + skills[key] = content; + console.log(` Embedded: ${folder} (local)`); } - if (Object.keys(skills).length === 0) { - console.error('ERROR: No skills were embedded.'); - process.exit(1); - } + const generated = generateEmbeddedSkillsFile(skills); + writeFileSync(OUTPUT_FILE, generated, 'utf-8'); - const fileContent = generateEmbeddedSkillsFile(skills, metadata); - writeFileSync(OUTPUT_FILE, fileContent); - console.log(`\nCreated: ${OUTPUT_FILE}`); - console.log( - `Embedded ${Object.keys(skills).length} skills (${metadata.length} package + ${localFolders.length} local).`, - ); + console.log(''); + console.log(`Created: ${OUTPUT_FILE}`); + console.log(`Embedded ${Object.keys(skills).length} local prompts.`); } main().catch((error) => { - console.error('Error embedding skills:', error); + console.error('Failed to embed local prompts:', error); process.exit(1); }); diff --git a/src/cli.tsx b/src/cli.tsx index 61d2ebe..6312468 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,13 +1,11 @@ import meow from 'meow'; import { agentCommand } from './commands/agent'; -import { chatCommand } from './commands/chat'; -import { debugCommand } from './commands/debug'; +import { doctorCommand } from './commands/doctor'; +import { installCommand } from './commands/install'; import { installMCPCommand } from './commands/install-mcp'; import { loginCommand } from './commands/login'; import { logoutCommand } from './commands/logout'; -import { resumeCommand } from './commands/resume'; import { setupCommand } from './commands/setup'; -import { skillCommand } from './commands/skill/index'; import { uninstallCommand } from './commands/uninstall'; import { updateCommand } from './commands/update'; import { whoamiCommand } from './commands/whoami'; @@ -17,35 +15,25 @@ import { isValidMCPAgent, type MCPTargetAgent, } from './lib/services/mcp-install-service'; -import { getAvailableSkills, getAvailableSkillTypes } from './lib/skills'; /** - * Generate help text dynamically based on available skills. + * Generate CLI help text. */ function generateHelpText(): string { - const skills = getAvailableSkills(); - const localSkills = skills.filter((s) => s.isLocal); - - // Generate local skill commands (command-line mode only) - const localSkillCommands = localSkills - .map((s) => ` ${s.type.padEnd(17)} ${s.description}`) - .join('\n'); - return ` Usage $ clix [command] [options] Commands - (default) Start interactive chat with AI agent + (default) Show this help message help Show this help message login Log in to Clix via browser logout Log out from Clix whoami Show current logged-in user agent [name] List or switch AI agents -${localSkillCommands} - debug Interactive debugging assistant + install Install Clix SDK (step-by-step setup + one-shot execution) + doctor Check Clix SDK integration status install-mcp [agent] Install Clix MCP Server - resume Resume a previous session uninstall Uninstall Clix CLI from your system update Check for available updates @@ -62,10 +50,8 @@ ${localSkillCommands} $ clix whoami $ clix agent $ clix agent claude - $ clix resume $ clix install $ clix doctor - $ clix debug "Push notifications not working on iOS" $ clix install-mcp $ clix install-mcp claude `; @@ -82,10 +68,6 @@ const cli = meow(generateHelpText(), { startTask: { type: 'string', }, - interactive: { - type: 'boolean', - default: false, - }, keepConfig: { type: 'boolean', default: false, @@ -109,9 +91,6 @@ const cli = meow(generateHelpText(), { async function main() { const command = cli.input[0]; - // Get available skill types dynamically - const skillTypes = getAvailableSkillTypes(); - try { // Check if first-run setup is needed if (await shouldRunSetup(command)) { @@ -141,14 +120,26 @@ async function main() { break; } - case 'debug': { - const problem = cli.input.slice(1).join(' '); - await debugCommand({ problem }); + case 'install': { + const platform = cli.flags.platform as + | 'ios' + | 'android' + | 'react-native' + | 'flutter' + | undefined; + const startTask = cli.flags.startTask; + await installCommand({ platform, startTask }); break; } - case 'resume': { - await resumeCommand(); + case 'doctor': { + const platform = cli.flags.platform as + | 'ios' + | 'android' + | 'react-native' + | 'flutter' + | undefined; + await doctorCommand({ platform }); break; } @@ -193,24 +184,14 @@ async function main() { } default: - // Check if command is a skill type (dynamically) - if (skillTypes.includes(command ?? '')) { - const platform = cli.flags.platform as - | 'ios' - | 'android' - | 'react-native' - | 'flutter' - | undefined; - const startTask = cli.flags.startTask; - await skillCommand({ action: command, platform, startTask }); - } else if (command) { + if (command) { // Unknown command - show error message console.error(`Unknown command: ${command}`); console.error(`Run 'clix help' to see available commands.`); process.exit(1); } else { - // No command provided - start interactive chat TUI - await chatCommand(); + // No command provided - show command help + cli.showHelp(); } break; } diff --git a/src/commands/chat.tsx b/src/commands/chat.tsx deleted file mode 100644 index 4fae532..0000000 --- a/src/commands/chat.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { AgentInfo } from '../lib/agents'; -import { getConfigManager } from '../lib/config/index'; -import { AgentSelectionService } from '../lib/services/agent-selection-service'; -import { loadChatSession, ONE_WEEK_MS } from '../lib/services/session-store'; -import { - checkForUpdate, - detectInstallationMethod, - shouldCheckForUpdate, - type UpdateCheckResult, -} from '../lib/services/update-service'; -import { ChatApp } from '../ui/chat/ChatApp'; -import { AgentSelector } from '../ui/components/AgentSelector'; -import { NoAgentGuide } from '../ui/components/NoAgentGuide'; -import { safeRender } from '../ui/utils/safeRender'; - -async function selectAgent(availableAgents: AgentInfo[]): Promise { - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(agent); - }} - />, - ); - }); -} - -export interface ChatCommandOptions { - resumeSessionId?: string; -} - -export async function chatCommand(options?: ChatCommandOptions): Promise { - // Load configuration - const config = getConfigManager(); - const cfg = await config.load(); - - // Start non-blocking update check if enabled - let updateCheckPromise: Promise<{ - result: UpdateCheckResult; - installMethod: string; - } | null> | null = null; - - if (!cfg.update?.disableAutoCheck && shouldCheckForUpdate(cfg.update?.lastCheckTime)) { - updateCheckPromise = (async () => { - try { - const [result, info] = await Promise.all([ - checkForUpdate(2000), - detectInstallationMethod(), - ]); - - // Save last check time - await config.save({ - update: { - ...cfg.update, - lastCheckTime: new Date().toISOString(), - lastKnownVersion: result.latestVersion ?? undefined, - }, - }); - - return { result, installMethod: info.method }; - } catch { - return null; - } - })(); - } - - // Load session to get preferred agent (if resuming) - let preferredAgentName: string | undefined; - if (options?.resumeSessionId) { - const session = await loadChatSession(options.resumeSessionId); - if (session && Date.now() - session.updatedAt <= ONE_WEEK_MS) { - preferredAgentName = session.currentAgentName ?? undefined; - } - } - - // Use AgentSelectionService for agent selection - const selectionService = new AgentSelectionService(config); - const result = await selectionService.selectAgent({ - mode: 'interactive', - allowPrompt: true, - preferredAgentName, - }); - - if (!result.agent && result.availableAgents.length === 0) { - // No agents available - show installation guide - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(); - }} - />, - ); - }); - } - - let selectedAgent: AgentInfo; - - if (result.needsUserSelection) { - // Multiple agents available, let user choose - selectedAgent = await selectAgent(result.availableAgents); - await selectionService.saveSelection(selectedAgent); - } else if (result.agent) { - selectedAgent = result.agent; - // Save selection if it was auto-selected - if (result.source === 'auto') { - await selectionService.saveSelection(selectedAgent); - } - } else { - // Should not reach here, but fallback to first available agent - selectedAgent = result.availableAgents[0]; - await selectionService.saveSelection(selectedAgent); - } - - // At this point selectedAgent is guaranteed to be defined - const agent = selectedAgent; - - // Render the chat app - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(); - }} - updateCheckPromise={updateCheckPromise} - disableUpdateNag={cfg.update?.disableUpdateNag ?? false} - initialSessionId={options?.resumeSessionId} - />, - { exitOnCtrlC: false }, - ); - }); -} diff --git a/src/commands/debug.tsx b/src/commands/debug.tsx deleted file mode 100644 index 60fe83e..0000000 --- a/src/commands/debug.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { AgentInfo } from '../lib/agents'; -import type { AgentExecutor, AgentMessage } from '../lib/executor'; -import { getDebugPrompt } from '../lib/services/debug-service'; -import { AgentExecutionUI } from '../ui/AgentExecutionUI'; -import { printFinalOutput } from '../ui/utils/finalOutput'; -import { safeRender } from '../ui/utils/safeRender'; - -interface DebugCommandOptions { - problem?: string; -} - -function createDebugExecute(problemDescription: string) { - return async function* execute( - executor: AgentExecutor, - _agent: AgentInfo, - ): AsyncGenerator { - const debugPrompt = getDebugPrompt({ - problemDescription, - projectPath: process.cwd(), - oneShot: true, - }); - - for await (const message of executor.execute(debugPrompt, { - workingDirectory: process.cwd(), - oneShot: true, - })) { - yield message; - } - }; -} - -export async function debugCommand(options: DebugCommandOptions): Promise { - const { problem } = options; - - if (!problem) { - console.log(` -Usage: clix debug - -Description: - Interactive debugging assistant that investigates your problem - and provides root cause analysis with recommended fixes. - -Arguments: - Description of the problem you're experiencing - -Examples: - $ clix debug "Push notifications not working on iOS" - $ clix debug "Events not appearing in Clix dashboard" - $ clix debug "Gradle build failing with dependency conflict" -`); - return; - } - - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - if (result) { - printFinalOutput(result); - } - resolve(); - }} - />, - ); - }); -} diff --git a/src/commands/doctor.tsx b/src/commands/doctor.tsx new file mode 100644 index 0000000..80c3852 --- /dev/null +++ b/src/commands/doctor.tsx @@ -0,0 +1,12 @@ +import { skillCommand } from './skill/index'; + +interface DoctorCommandOptions { + platform?: 'ios' | 'android' | 'react-native' | 'flutter'; +} + +export async function doctorCommand(options: DoctorCommandOptions = {}): Promise { + await skillCommand({ + action: 'doctor', + platform: options.platform, + }); +} diff --git a/src/commands/install.tsx b/src/commands/install.tsx index 0cded99..13acb70 100644 --- a/src/commands/install.tsx +++ b/src/commands/install.tsx @@ -1,74 +1,14 @@ -import type { AgentInfo } from '../lib/agents'; -import type { AgentExecutor, AgentMessage } from '../lib/executor'; -import { MCPInstaller } from '../lib/mcp'; -import { PromptFetcher } from '../lib/prompt'; -import { AgentExecutionUI } from '../ui/AgentExecutionUI'; -import { printFinalOutput } from '../ui/utils/finalOutput'; -import { safeRender } from '../ui/utils/safeRender'; -import { configCommand } from './config'; +import { skillCommand } from './skill/index'; -interface InstallOptions { - promptUrl?: string; +interface InstallCommandOptions { + platform?: 'ios' | 'android' | 'react-native' | 'flutter'; + startTask?: string; } -const ONE_SHOT_INSTRUCTION = ` -IMPORTANT: This is a non-interactive one-shot execution. Do NOT ask for user input or confirmation. -- Make reasonable assumptions based on the project structure -- Choose the most common/recommended approach when multiple options exist -- Proceed with the task autonomously without waiting for user response -- If critical information is missing, state your assumptions and proceed - -`; - -export async function installCommand(options: InstallOptions = {}): Promise { - // Store prompt in closure for execute function - let prompt = ''; - - // Prepare function: MCP installation and prompt fetch - async function prepare(agent: AgentInfo): Promise { - const mcpInstaller = new MCPInstaller(); - await mcpInstaller.ensureServerInstalled(agent.name); - - const fetcher = new PromptFetcher(); - prompt = await fetcher.fetch(options.promptUrl); - prompt = ONE_SHOT_INSTRUCTION + prompt; - } - - // Execute function: run the prompt - async function* execute( - executor: AgentExecutor, - _agent: AgentInfo, - ): AsyncGenerator { - yield* executor.execute(prompt, { oneShot: true }); - } - - return new Promise((resolve, reject) => { - const { unmount } = safeRender( - { - unmount(); - if (result) { - printFinalOutput(result); - } - resolve(); - }} - onNeedsConfig={async () => { - unmount(); - try { - // Run config command first - await configCommand(); - // After config, retry install - await installCommand(options); - resolve(); - } catch (error) { - reject(error); - } - }} - />, - ); +export async function installCommand(options: InstallCommandOptions = {}): Promise { + await skillCommand({ + action: 'install', + platform: options.platform, + startTask: options.startTask, }); } diff --git a/src/commands/resume.tsx b/src/commands/resume.tsx deleted file mode 100644 index 61b0b5c..0000000 --- a/src/commands/resume.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { listChatSessions } from '@/lib/services/session-store'; -import { SessionSelector } from '@/ui/components/SessionSelector'; -import { safeRender } from '@/ui/utils/safeRender'; -import { chatCommand } from './chat'; - -export async function resumeCommand(): Promise { - const sessions = await listChatSessions(); - - if (sessions.length === 0) { - console.log('No saved sessions found (last 7 days).'); - return; - } - - if (sessions.length === 1) { - await chatCommand({ resumeSessionId: sessions[0].id }); - return; - } - - const selected = await new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - resolve(s.id); - }} - onCancel={() => { - unmount(); - resolve(null); - }} - />, - ); - }); - - if (!selected) { - return; - } - - await chatCommand({ resumeSessionId: selected }); -} diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index b7225f9..04b9a6a 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -1,11 +1,7 @@ +import type { AgentInfo } from '../../lib/agents'; import type { AgentExecutor, AgentMessage } from '../../lib/executor'; -import { - executeSkill, - getAvailableSkills, - getAvailableSkillTypes, - getSkillInfo, - type SkillType, -} from '../../lib/skills'; +import { ensureVercelSkillsInstalled } from '../../lib/services/vercel-skills'; +import { executeSkill } from '../../lib/skills'; import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; import { InstallPreparationUI } from '../../ui/components/InstallPreparationUI'; import { @@ -22,48 +18,48 @@ interface SkillCommandOptions { startTask?: string; } -/** - * Generate help text dynamically from available skills. - */ -function generateHelpText(): string { - const skills = getAvailableSkills(); - const localSkills = skills.filter((s) => s.isLocal); - const packageSkills = skills.filter((s) => !s.isLocal); - - const localSkillsText = localSkills - .map((s) => ` ${s.type.padEnd(25)} ${s.description}`) - .join('\n'); +type CommandSkillType = 'install' | 'doctor'; - const packageSkillsText = packageSkills - .map((s) => ` ${s.type.padEnd(25)} ${s.description} (interactive)`) - .join('\n'); - - const exampleSkill = localSkills[0]?.type ?? 'install'; +const COMMAND_SKILLS: Record< + CommandSkillType, + { + title: string; + description: string; + } +> = { + install: { + title: 'SDK Installation', + description: 'Autonomous SDK integration with automatic file modifications', + }, + doctor: { + title: 'SDK Doctor', + description: 'Check Clix SDK integration status', + }, +}; + +function isCommandSkillType(action: string): action is CommandSkillType { + return action === 'install' || action === 'doctor'; +} +function generateHelpText(): string { return ` -Usage: clix [options] - -Available skills (command-line mode): -${localSkillsText} +Usage: clix [options] -Additional skills (chat mode only): -${packageSkillsText} +Supported commands: + install Autonomous SDK integration with step-by-step preparation + doctor Check Clix SDK integration status Options: - --platform Target platform (ios, android, react-native, flutter) - -Note: Interactive skills require step-by-step guidance. - Run 'clix' to start chat mode and use / commands. + --platform Target platform (ios, android, react-native, flutter) + --start-task Development-only install task override Examples: - $ clix ${exampleSkill} + $ clix install + $ clix install --platform ios $ clix doctor `; } -/** - * Run install preparation UI and return the context. - */ async function runInstallPreparation( projectPath: string, startTaskId?: InstallTaskId, @@ -91,36 +87,24 @@ async function runInstallPreparation( }); } +async function ensureSkillsReady(agent: AgentInfo, commandName: string): Promise { + const ready = await ensureVercelSkillsInstalled(agent, commandName, process.cwd()); + if (!ready) { + throw new Error('Required Vercel Skills are not installed.'); + } +} + export async function skillCommand(options: SkillCommandOptions): Promise { const { action, platform, startTask } = options; - if (!action || !isValidSkillType(action)) { + if (!action || !isCommandSkillType(action)) { console.log(generateHelpText()); return; } - // Check if skill supports command-line execution - const skillInfo = getSkillInfo(action as SkillType); - if (!skillInfo) { - console.error(`Skill not found: ${action}`); - process.exit(1); - } - - if (!skillInfo.isLocal) { - console.error(`Skill '${action}' requires interactive mode.`); - console.error(`Please run 'clix' to start chat mode and use /${action}`); - 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; + const skillType: CommandSkillType = action; const projectPath = process.cwd(); + const commandInfo = COMMAND_SKILLS[skillType]; let startTaskId: InstallTaskId | undefined; if (startTask) { @@ -145,18 +129,19 @@ export async function skillCommand(options: SkillCommandOptions): Promise startTaskId = startTask as InstallTaskId; } - // For install skill, run preparation first let preparationContext: PreparationContext | undefined; if (skillType === 'install') { const context = await runInstallPreparation(projectPath, startTaskId); if (!context) { - // User cancelled or config missing return; } preparationContext = context; } - // Create execute function that wraps executeSkill + async function prepare(agent: AgentInfo): Promise { + await ensureSkillsReady(agent, `clix ${skillType}`); + } + async function* executeCommand(executor: AgentExecutor): AsyncGenerator { yield* executeSkill(skillType, executor, { projectPath, @@ -166,16 +151,19 @@ export async function skillCommand(options: SkillCommandOptions): Promise }); } - // Wrapper to match AgentExecutionUI interface (ignores agent param) - async function* execute(executor: AgentExecutor, _agent: unknown): AsyncGenerator { + async function* execute( + executor: AgentExecutor, + _agent: AgentInfo, + ): AsyncGenerator { yield* executeCommand(executor); } return new Promise((resolve) => { const { unmount } = safeRender( { unmount(); @@ -188,7 +176,3 @@ export async function skillCommand(options: SkillCommandOptions): Promise ); }); } - -function isValidSkillType(action: string): action is SkillType { - return getAvailableSkillTypes().includes(action); -} diff --git a/src/lib/__tests__/embedded-skills.test.ts b/src/lib/__tests__/embedded-skills.test.ts index 3ae5a4a..8f0ee77 100644 --- a/src/lib/__tests__/embedded-skills.test.ts +++ b/src/lib/__tests__/embedded-skills.test.ts @@ -1,11 +1,3 @@ -/** - * Tests for embedded skills to ensure build-time skill embedding works correctly. - * - * These tests validate that: - * 1. All required skills are embedded - * 2. Embedded skills contain valid content - * 3. The embedding matches the expected skill folder structure - */ import { describe, expect, test } from 'bun:test'; import { EMBEDDED_SKILL_METADATA, @@ -17,288 +9,28 @@ import { hasEmbeddedSkills, } from '../embedded-skills'; -/** - * Required skill folders that MUST be embedded for the binary to work. - * This must match SKILL_FOLDER_MAP in skills.ts - */ -const REQUIRED_SKILL_FOLDERS = [ - 'integration', - 'event-tracking', - 'user-management', - 'personalization', -]; - -describe('embedded-skills', () => { - describe('build-time embedding validation', () => { - test('hasEmbeddedSkills returns true when skills are embedded', () => { - expect(hasEmbeddedSkills()).toBe(true); - }); - - test('all required skills are embedded', () => { - const embeddedFolders = getEmbeddedSkillFolders(); - - for (const folder of REQUIRED_SKILL_FOLDERS) { - expect(embeddedFolders).toContain(folder); - } - }); - - test('EMBEDDED_SKILLS contains all required skills', () => { - for (const folder of REQUIRED_SKILL_FOLDERS) { - expect(EMBEDDED_SKILLS[folder]).toBeDefined(); - expect(typeof EMBEDDED_SKILLS[folder]).toBe('string'); - expect(EMBEDDED_SKILLS[folder].length).toBeGreaterThan(0); - } - }); - - test('embedded skills count includes at least required skills', () => { - // May have additional skills beyond the required ones - expect(Object.keys(EMBEDDED_SKILLS).length).toBeGreaterThanOrEqual( - REQUIRED_SKILL_FOLDERS.length, - ); - }); - }); - - describe('getEmbeddedSkill', () => { - test('returns skill content for valid folder', () => { - const skill = getEmbeddedSkill('integration'); - expect(skill).toBeDefined(); - expect(typeof skill).toBe('string'); - expect(skill?.length).toBeGreaterThan(0); - }); - - test('returns undefined for invalid folder', () => { - const skill = getEmbeddedSkill('non-existent-skill'); - expect(skill).toBeUndefined(); - }); - - test('returns correct content for each skill type', () => { - // Each skill should have a recognizable marker in its content - const skillMarkers: Record = { - integration: 'clix-integration', - 'event-tracking': 'clix-event-tracking', - 'user-management': 'clix-user-management', - personalization: 'clix-personalization', - }; - - for (const [folder, marker] of Object.entries(skillMarkers)) { - const skill = getEmbeddedSkill(folder); - expect(skill).toBeDefined(); - expect(skill).toContain(marker); - } - }); - }); - - describe('skill content validation', () => { - test('integration skill contains SDK integration content', () => { - const skill = getEmbeddedSkill('integration'); - expect(skill).toContain('SDK'); - expect(skill).toContain('integration'); - }); - - test('event-tracking skill contains event tracking content', () => { - const skill = getEmbeddedSkill('event-tracking'); - expect(skill).toContain('trackEvent'); - }); - - test('user-management skill contains user management content', () => { - const skill = getEmbeddedSkill('user-management'); - expect(skill).toContain('setUserId'); - }); - - test('personalization skill contains personalization content', () => { - const skill = getEmbeddedSkill('personalization'); - expect(skill).toContain('personalization'); - }); - }); - - describe('getEmbeddedSkillFolders', () => { - test('returns array of folder names', () => { - const folders = getEmbeddedSkillFolders(); - expect(Array.isArray(folders)).toBe(true); - expect(folders.length).toBeGreaterThanOrEqual(REQUIRED_SKILL_FOLDERS.length); - }); - - test('includes all required skill folders', () => { - const folders = getEmbeddedSkillFolders(); - for (const required of REQUIRED_SKILL_FOLDERS) { - expect(folders).toContain(required); - } - }); - - test('folder names match EMBEDDED_SKILLS keys', () => { - const folders = getEmbeddedSkillFolders(); - const keys = Object.keys(EMBEDDED_SKILLS); - expect(folders.sort()).toEqual(keys.sort()); - }); - }); -}); - -describe('embedded skill metadata', () => { - /** - * Tests for dynamic skill discovery via EMBEDDED_SKILL_METADATA. - * This is critical for automatic command registration when new skills are added. - */ - - describe('EMBEDDED_SKILL_METADATA structure', () => { - test('contains metadata for all package skills', () => { - // Filter out local skills (prefixed with 'local-') as they don't have metadata - const packageSkills = Object.keys(EMBEDDED_SKILLS).filter((key) => !key.startsWith('local-')); - expect(EMBEDDED_SKILL_METADATA.length).toBe(packageSkills.length); - }); - - test('each metadata entry has all required fields', () => { - for (const meta of EMBEDDED_SKILL_METADATA) { - expect(meta.folder).toBeDefined(); - expect(meta.name).toBeDefined(); - expect(meta.description).toBeDefined(); - expect(typeof meta.userInvocable).toBe('boolean'); - expect(meta.commandName).toBeDefined(); - expect(meta.displayName).toBeDefined(); - } - }); - - test('metadata folders match package skill keys', () => { - const metadataFolders = EMBEDDED_SKILL_METADATA.map((m) => m.folder).sort(); - // Filter out local skills (prefixed with 'local-') as they don't have metadata - const packageSkills = Object.keys(EMBEDDED_SKILLS) - .filter((key) => !key.startsWith('local-')) - .sort(); - expect(metadataFolders).toEqual(packageSkills); - }); - - test('all embedded skills are user-invocable', () => { - // All skills in EMBEDDED_SKILL_METADATA should be user-invocable - // (non-user-invocable skills are filtered out during embedding) - for (const meta of EMBEDDED_SKILL_METADATA) { - expect(meta.userInvocable).toBe(true); - } - }); +describe('embedded-skills (local prompts only)', () => { + test('contains embedded local prompts', () => { + expect(hasEmbeddedSkills()).toBe(true); + expect(EMBEDDED_SKILLS['local-install']).toBeDefined(); + expect(EMBEDDED_SKILLS['local-doctor']).toBeDefined(); }); - describe('getSkillMetadataByCommand', () => { - test('returns metadata for valid command name', () => { - const meta = getSkillMetadataByCommand('integration'); - expect(meta).toBeDefined(); - expect(meta?.commandName).toBe('integration'); - expect(meta?.folder).toBe('integration'); - }); - - test('returns undefined for invalid command name', () => { - const meta = getSkillMetadataByCommand('non-existent'); - expect(meta).toBeUndefined(); - }); - - test('works for all embedded skills', () => { - for (const skill of EMBEDDED_SKILL_METADATA) { - const meta = getSkillMetadataByCommand(skill.commandName); - expect(meta).toBeDefined(); - expect(meta?.folder).toBe(skill.folder); - } - }); + test('returns embedded prompt by folder key', () => { + const installPrompt = getEmbeddedSkill('local-install'); + expect(installPrompt).toBeDefined(); + expect(installPrompt).toContain('Install phase'); }); - describe('getSkillMetadataByFolder', () => { - test('returns metadata for valid folder name', () => { - const meta = getSkillMetadataByFolder('integration'); - expect(meta).toBeDefined(); - expect(meta?.folder).toBe('integration'); - expect(meta?.commandName).toBe('integration'); - }); - - test('returns undefined for invalid folder name', () => { - const meta = getSkillMetadataByFolder('non-existent'); - expect(meta).toBeUndefined(); - }); - - test('works for all package skills', () => { - // Filter out local skills (prefixed with 'local-') as they don't have metadata - const packageSkills = Object.keys(EMBEDDED_SKILLS).filter((key) => !key.startsWith('local-')); - for (const folder of packageSkills) { - const meta = getSkillMetadataByFolder(folder); - expect(meta).toBeDefined(); - expect(meta?.folder).toBe(folder); - } - }); - - test('returns undefined for local skills', () => { - // Local skills don't have metadata - const meta = getSkillMetadataByFolder('local-install'); - expect(meta).toBeUndefined(); - }); - }); -}); - -describe('dynamic skill discovery', () => { - /** - * Tests verifying that new skills from @clix-so/clix-agent-skills - * are automatically discovered and available. - */ - - test('api-triggered-campaigns skill is discovered', () => { - // This skill was added after the initial implementation - // and should be automatically discovered + test('returns all embedded prompt keys', () => { const folders = getEmbeddedSkillFolders(); - expect(folders).toContain('api-triggered-campaigns'); + expect(folders).toContain('local-install'); + expect(folders).toContain('local-doctor'); }); - test('api-triggered-campaigns has valid metadata', () => { - const meta = getSkillMetadataByFolder('api-triggered-campaigns'); - expect(meta).toBeDefined(); - expect(meta?.commandName).toBe('api-triggered-campaigns'); - expect(meta?.displayName).toBe('API-Triggered Campaigns'); - expect(meta?.userInvocable).toBe(true); - }); - - test('api-triggered-campaigns is accessible via getEmbeddedSkill', () => { - const skill = getEmbeddedSkill('api-triggered-campaigns'); - expect(skill).toBeDefined(); - expect(typeof skill).toBe('string'); - expect(skill?.length).toBeGreaterThan(0); - }); - - test('new skills result in automatic command availability', async () => { - // Import skills.ts to verify dynamic command availability - const { getAvailableSkills } = await import('../skills'); - const skills = getAvailableSkills(); - - // api-triggered-campaigns command should be available - const apiCampaign = skills.find((s) => s.type === 'api-triggered-campaigns'); - expect(apiCampaign).toBeDefined(); - expect(apiCampaign?.name).toBe('API-Triggered Campaigns'); - }); -}); - -describe('skills.ts integration with embedded skills', () => { - /** - * These tests verify that skills.ts correctly falls back to embedded skills. - * Since we're in the dev environment, the external package is available, - * but we can still verify the fallback logic works. - */ - - test('all skill types resolve to valid prompts', async () => { - // Import the skill folder map from skills.ts indirectly through getSkillPrompt - const { getSkillPrompt, getAvailableSkillTypes } = await import('../skills'); - - // Get all available skill types dynamically - const skillTypes = getAvailableSkillTypes(); - - // Each skill type should resolve to a skill (either from package or embedded) - for (const skillType of skillTypes) { - const prompt = await getSkillPrompt(skillType); - expect(prompt).toBeDefined(); - expect(typeof prompt).toBe('string'); - expect(prompt.length).toBeGreaterThan(0); - } - }); - - test('embedded skill count matches command count (excluding local skills)', async () => { - const { getAvailableSkills } = await import('../skills'); - const skills = getAvailableSkills(); - - // Embedded skills + local skills = total available skills - const embeddedCount = EMBEDDED_SKILL_METADATA.length; - const localSkillCount = skills.filter((s) => s.isLocal).length; - - expect(skills.length).toBe(embeddedCount + localSkillCount); + test('package metadata list stays empty', () => { + expect(EMBEDDED_SKILL_METADATA).toEqual([]); + expect(getSkillMetadataByCommand('integration')).toBeUndefined(); + expect(getSkillMetadataByFolder('integration')).toBeUndefined(); }); }); diff --git a/src/lib/__tests__/history-compaction.test.ts b/src/lib/__tests__/history-compaction.test.ts deleted file mode 100644 index acb2933..0000000 --- a/src/lib/__tests__/history-compaction.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; -import type { AgentMessage, ConversationMessage } from '../executor'; -import { - COMPACTION_PRESERVE_RATIO, - calculateHistorySize, - DEFAULT_COMPACTION_THRESHOLD, - findCompactionSplitPoint, - generateCompressionPrompt, - getCompactionService, - HistoryCompactionService, - MAX_SUMMARY_LENGTH, -} from '../services/history-compaction'; -import { - createMockExecutor, - createMockExecutorWithResponses, - createMockHistory, - FIXTURES, -} from './test-utils'; - -describe('calculateHistorySize', () => { - test('should return 0 for empty history', () => { - expect(calculateHistorySize([])).toBe(0); - }); - - test('should sum up content lengths for all messages', () => { - const size = calculateHistorySize(FIXTURES.shortHistory); - // 'Hello' (5) + 'Hi there! How can I help you today?' (35) = 40 - expect(size).toBe(40); - }); - - test('should handle single message', () => { - const size = calculateHistorySize([{ role: 'user', content: 'test' }]); - expect(size).toBe(4); - }); - - test('should correctly calculate size for long history', () => { - const size = calculateHistorySize(FIXTURES.longHistory); - // Each message is ~500 chars, 20 messages - expect(size).toBeGreaterThan(9000); - }); -}); - -describe('findCompactionSplitPoint', () => { - test('should return 0 for empty history', () => { - expect(findCompactionSplitPoint([], 0.3)).toBe(0); - }); - - test('should throw error for invalid preserve ratio (0)', () => { - expect(() => findCompactionSplitPoint(FIXTURES.shortHistory, 0)).toThrow( - 'Preserve ratio must be between 0 and 1', - ); - }); - - test('should throw error for invalid preserve ratio (1)', () => { - expect(() => findCompactionSplitPoint(FIXTURES.shortHistory, 1)).toThrow( - 'Preserve ratio must be between 0 and 1', - ); - }); - - test('should throw error for invalid preserve ratio (negative)', () => { - expect(() => findCompactionSplitPoint(FIXTURES.shortHistory, -0.5)).toThrow( - 'Preserve ratio must be between 0 and 1', - ); - }); - - test('should throw error for invalid preserve ratio (>1)', () => { - expect(() => findCompactionSplitPoint(FIXTURES.shortHistory, 1.5)).toThrow( - 'Preserve ratio must be between 0 and 1', - ); - }); - - test('should find split point on user message boundary', () => { - const history = createMockHistory(10, 100); - const splitPoint = findCompactionSplitPoint(history, 0.3); - // Split should be on a user message (even index) - expect(splitPoint % 2).toBe(0); - }); - - test('should preserve approximately the expected ratio of content', () => { - const history = createMockHistory(20, 100); - const preserveRatio = 0.3; - const splitPoint = findCompactionSplitPoint(history, preserveRatio); - - const totalSize = calculateHistorySize(history); - const preservedSize = calculateHistorySize(history.slice(splitPoint)); - - // The preserved portion should be roughly around the preserve ratio - const actualRatio = preservedSize / totalSize; - expect(actualRatio).toBeGreaterThanOrEqual(0.2); - expect(actualRatio).toBeLessThanOrEqual(0.5); - }); -}); - -describe('generateCompressionPrompt', () => { - test('should include all messages in the prompt', () => { - const prompt = generateCompressionPrompt(FIXTURES.shortHistory); - - expect(prompt).toContain('[USER]: Hello'); - expect(prompt).toContain('[ASSISTANT]: Hi there! How can I help you today?'); - }); - - test('should include max summary length instruction', () => { - const prompt = generateCompressionPrompt(FIXTURES.shortHistory); - expect(prompt).toContain(`${MAX_SUMMARY_LENGTH} characters`); - }); - - test('should include conversation tags', () => { - const prompt = generateCompressionPrompt(FIXTURES.shortHistory); - expect(prompt).toContain(''); - expect(prompt).toContain(''); - }); - - test('should include key summarization instructions', () => { - const prompt = generateCompressionPrompt(FIXTURES.shortHistory); - expect(prompt).toContain('Key topics discussed'); - expect(prompt).toContain('Important decisions made'); - expect(prompt).toContain('code or technical details'); - expect(prompt).toContain('User preferences or requirements'); - }); -}); - -describe('HistoryCompactionService', () => { - let service: HistoryCompactionService; - let mockExecutor: ReturnType; - - beforeEach(() => { - mockExecutor = createMockExecutor(); - }); - - describe('constructor', () => { - test('should use default threshold and preserve ratio', () => { - service = new HistoryCompactionService(); - // Small history shouldn't need compaction with default threshold - expect(service.needsCompaction(FIXTURES.shortHistory)).toBe(false); - }); - - test('should accept custom threshold', () => { - service = new HistoryCompactionService({ threshold: 10 }); - // Even short history should need compaction with tiny threshold - expect(service.needsCompaction(FIXTURES.shortHistory)).toBe(true); - }); - - test('should accept custom preserve ratio', () => { - service = new HistoryCompactionService({ - threshold: 10, - preserveRatio: 0.5, - }); - expect(service.needsCompaction(FIXTURES.shortHistory)).toBe(true); - }); - }); - - describe('needsCompaction', () => { - test('should return false when history is below threshold', () => { - service = new HistoryCompactionService({ threshold: 100000 }); - expect(service.needsCompaction(FIXTURES.shortHistory)).toBe(false); - }); - - test('should return true when history is at or above threshold', () => { - service = new HistoryCompactionService({ threshold: 30 }); - expect(service.needsCompaction(FIXTURES.shortHistory)).toBe(true); - }); - - test('should return false for empty history', () => { - service = new HistoryCompactionService({ threshold: 10 }); - expect(service.needsCompaction([])).toBe(false); - }); - }); - - describe('compact', () => { - test('should not compact when below threshold', async () => { - service = new HistoryCompactionService({ threshold: 100000 }); - const { newHistory, result } = await service.compact(FIXTURES.mediumHistory, mockExecutor); - - expect(result.compacted).toBe(false); - expect(newHistory).toEqual(FIXTURES.mediumHistory); - expect(result.messagesCompacted).toBe(0); - expect(result.messagesPreserved).toBe(FIXTURES.mediumHistory.length); - }); - - test('should not compact history with fewer than 4 messages', async () => { - service = new HistoryCompactionService({ threshold: 10 }); - const { newHistory, result } = await service.compact(FIXTURES.shortHistory, mockExecutor); - - expect(result.compacted).toBe(false); - expect(newHistory).toEqual(FIXTURES.shortHistory); - }); - - test('should compact when force option is true', async () => { - service = new HistoryCompactionService({ threshold: 100000 }); - - const summaryExecutor = createMockExecutorWithResponses([ - { type: 'text', content: 'Summary of the conversation' }, - { type: 'complete', content: '' }, - ]); - - const { newHistory, result } = await service.compact( - FIXTURES.mediumHistory, - summaryExecutor, - { force: true }, - ); - - expect(result.compacted).toBe(true); - expect(newHistory.length).toBeLessThan(FIXTURES.mediumHistory.length); - }); - - test('should compress older messages and preserve recent ones', async () => { - service = new HistoryCompactionService({ threshold: 10, preserveRatio: 0.5 }); - - const summaryExecutor = createMockExecutorWithResponses([ - { type: 'text', content: 'Compressed summary' }, - { type: 'complete', content: '' }, - ]); - - const { newHistory, result } = await service.compact( - FIXTURES.mediumHistory, - summaryExecutor, - { force: true }, - ); - - expect(result.compacted).toBe(true); - // New history should have summary + acknowledgment + preserved messages - expect(newHistory.length).toBeGreaterThanOrEqual(3); - // First message should be the summary - expect(newHistory[0].role).toBe('user'); - expect(newHistory[0].content).toContain('[Previous conversation summary]'); - }); - - test('should handle executor errors gracefully', async () => { - service = new HistoryCompactionService({ threshold: 10 }); - - const errorExecutor = createMockExecutor({ - // biome-ignore lint/correctness/useYield: Test intentionally throws before yielding - execute: mock(async function* (): AsyncGenerator { - throw new Error('Executor error'); - }), - }); - - // Mock console.error to prevent test noise - const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}); - - const { newHistory, result } = await service.compact(FIXTURES.mediumHistory, errorExecutor, { - force: true, - }); - - expect(result.compacted).toBe(false); - expect(newHistory).toEqual(FIXTURES.mediumHistory); - - consoleSpy.mockRestore(); - }); - - test('should truncate summary if too long', async () => { - service = new HistoryCompactionService({ threshold: 10 }); - - // Create an executor that returns a very long summary - const longSummary = 'x'.repeat(MAX_SUMMARY_LENGTH + 1000); - const longSummaryExecutor = createMockExecutorWithResponses([ - { type: 'text', content: longSummary }, - { type: 'complete', content: '' }, - ]); - - const { newHistory, result } = await service.compact( - FIXTURES.mediumHistory, - longSummaryExecutor, - { force: true }, - ); - - expect(result.compacted).toBe(true); - // Summary should be truncated with ... - const summaryContent = newHistory[0].content; - expect(summaryContent.length).toBeLessThanOrEqual( - MAX_SUMMARY_LENGTH + '[Previous conversation summary]\n'.length + 10, - ); - }); - - test('should include acknowledgment message after summary', async () => { - service = new HistoryCompactionService({ threshold: 10 }); - - const summaryExecutor = createMockExecutorWithResponses([ - { type: 'text', content: 'Test summary' }, - { type: 'complete', content: '' }, - ]); - - const { newHistory } = await service.compact(FIXTURES.mediumHistory, summaryExecutor, { - force: true, - }); - - expect(newHistory[1].role).toBe('assistant'); - expect(newHistory[1].content).toContain('context from our previous conversation'); - }); - - test('should respect custom threshold in options', async () => { - service = new HistoryCompactionService({ threshold: 100000 }); - - const summaryExecutor = createMockExecutorWithResponses([ - { type: 'text', content: 'Summary' }, - { type: 'complete', content: '' }, - ]); - - // Use small threshold in options to force compaction - const { result } = await service.compact(FIXTURES.mediumHistory, summaryExecutor, { - threshold: 10, - }); - - expect(result.compacted).toBe(true); - }); - - test('should clear and restore executor history during compression', async () => { - service = new HistoryCompactionService({ threshold: 10 }); - - const originalHistory: ConversationMessage[] = [ - { role: 'user', content: 'Original message' }, - ]; - - const summaryExecutor = createMockExecutor({ - execute: mock(async function* (): AsyncGenerator { - yield { type: 'text', content: 'Summary' }; - yield { type: 'complete', content: '' }; - }), - }); - - summaryExecutor.setHistory(originalHistory); - - await service.compact(FIXTURES.mediumHistory, summaryExecutor, { force: true }); - - // History should be restored after compression - expect(summaryExecutor.getHistory).toHaveBeenCalled(); - expect(summaryExecutor.setHistory).toHaveBeenCalled(); - }); - }); -}); - -describe('getCompactionService', () => { - test('should return a singleton instance', () => { - const service1 = getCompactionService(); - const service2 = getCompactionService(); - expect(service1).toBe(service2); - }); - - test('should return a HistoryCompactionService instance', () => { - const service = getCompactionService(); - expect(service).toBeInstanceOf(HistoryCompactionService); - }); -}); - -describe('Constants', () => { - test('DEFAULT_COMPACTION_THRESHOLD should be calculated correctly', () => { - // 200000 * 0.9 * 4 = 720000 - expect(DEFAULT_COMPACTION_THRESHOLD).toBe(720000); - }); - - test('COMPACTION_PRESERVE_RATIO should be 0.3', () => { - expect(COMPACTION_PRESERVE_RATIO).toBe(0.3); - }); - - test('MAX_SUMMARY_LENGTH should be 4000', () => { - expect(MAX_SUMMARY_LENGTH).toBe(4000); - }); -}); diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index bd738d3..f4fd5b9 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -1,732 +1,96 @@ -import { describe, expect, test } from 'bun:test'; -import { EMBEDDED_SKILLS, getEmbeddedSkill, hasEmbeddedSkills } from '../embedded-skills'; -import type { AgentMessage } from '../executor'; +import { describe, expect, mock, test } from 'bun:test'; +import type { AgentMessage, ExecuteOptions } from '../executor'; import { AVAILABLE_SKILLS, executeSkill, + getAvailableSkillTypes, getSkillInfo, getSkillPrompt, - type SkillType, } from '../skills'; -import { createMockExecutorWithResponses, FIXTURES } from './test-utils'; +import { createMockExecutor, createMockExecutorWithResponses } from './test-utils'; -describe('AVAILABLE_SKILLS', () => { - test('should contain at least 5 skills (embedded + local)', () => { - // 5 embedded skills from @clix-so/clix-agent-skills + public local skills - expect(AVAILABLE_SKILLS.length).toBeGreaterThanOrEqual(5); +describe('skills (command-only)', () => { + test('exposes local command skills only', () => { + expect(getAvailableSkillTypes()).toEqual(['install', 'doctor']); + expect(AVAILABLE_SKILLS.map((s) => s.type)).toEqual(['install', 'doctor']); + expect(getSkillInfo('integration')).toBeUndefined(); }); - test('should have integration skill', () => { - const skill = AVAILABLE_SKILLS.find((s) => s.type === 'integration'); + test('returns install skill metadata', () => { + const skill = getSkillInfo('install'); expect(skill).toBeDefined(); - expect(skill?.name).toBe('SDK Integration'); - }); - - test('install skill should be a separate local skill', () => { - // 'clix install' is now a separate autonomous installation skill - const { getAvailableSkillTypes, getSkillInfo } = require('../skills'); - const skillTypes = getAvailableSkillTypes(); - expect(skillTypes).toContain('install'); - expect(skillTypes).not.toContain('project-build'); - - const installSkill = getSkillInfo('install'); - expect(installSkill?.isLocal).toBe(true); - expect(installSkill?.name).toBe('SDK Installation'); - }); - - test('should have event-tracking skill', () => { - const skill = AVAILABLE_SKILLS.find((s) => s.type === 'event-tracking'); - expect(skill).toBeDefined(); - expect(skill?.name).toBe('Event Tracking'); - }); - - test('should have user-management skill', () => { - const skill = AVAILABLE_SKILLS.find((s) => s.type === 'user-management'); - expect(skill).toBeDefined(); - expect(skill?.name).toBe('User Management'); - }); - - test('should have personalization skill', () => { - const skill = AVAILABLE_SKILLS.find((s) => s.type === 'personalization'); - expect(skill).toBeDefined(); - expect(skill?.name).toBe('Personalization'); - }); - - test('should have doctor skill', () => { - const skill = AVAILABLE_SKILLS.find((s) => s.type === 'doctor'); - expect(skill).toBeDefined(); - expect(skill?.name).toBe('SDK Doctor'); - }); - - test('each skill should have type, name, and description', () => { - for (const skill of AVAILABLE_SKILLS) { - expect(skill.type).toBeDefined(); - expect(skill.name).toBeDefined(); - expect(skill.description).toBeDefined(); - expect(typeof skill.type).toBe('string'); - expect(typeof skill.name).toBe('string'); - expect(typeof skill.description).toBe('string'); - } - }); -}); - -describe('getSkillInfo', () => { - test('should return skill info for valid skill type', () => { - const skill = getSkillInfo('integration'); - expect(skill).toBeDefined(); - expect(skill?.type).toBe('integration'); - expect(skill?.name).toBe('SDK Integration'); - }); - - test('should return undefined for invalid skill type', () => { - const skill = getSkillInfo('invalid' as unknown as SkillType); - expect(skill).toBeUndefined(); - }); - - test('should return correct info for doctor skill', () => { - const skill = getSkillInfo('doctor'); - expect(skill).toBeDefined(); - expect(skill?.type).toBe('doctor'); - expect(skill?.description).toContain('Check'); - }); - - test('should return all skill types correctly', () => { - for (const skillType of FIXTURES.skillTypes) { - const skill = getSkillInfo(skillType); - expect(skill).toBeDefined(); - expect(skill?.type).toBe(skillType); - } + expect(skill?.isLocal).toBe(true); + expect(skill?.name).toBe('SDK Installation'); }); }); describe('getSkillPrompt', () => { - describe('doctor skill (local)', () => { - test('should return doctor prompt with project path', async () => { - const prompt = await getSkillPrompt('doctor', { - projectPath: '/test/project', - }); - - expect(prompt).toContain('Project path: /test/project'); - expect(prompt).toContain('analyzing a mobile project for Clix SDK'); - }); - - test('should include diagnostic JSON structure', async () => { - const prompt = await getSkillPrompt('doctor'); - - expect(prompt).toContain('"platform"'); - expect(prompt).toContain('"sdkInstalled"'); - expect(prompt).toContain('"pushConfigured"'); - expect(prompt).toContain('"issues"'); - expect(prompt).toContain('"checklist"'); - }); - - test('should include platform detection instructions', async () => { - const prompt = await getSkillPrompt('doctor'); + test('builds doctor prompt with project path', async () => { + const prompt = await getSkillPrompt('doctor', { projectPath: '/tmp/project' }); - expect(prompt).toContain('package.json'); - expect(prompt).toContain('pubspec.yaml'); - expect(prompt).toContain('xcodeproj'); - expect(prompt).toContain('build.gradle'); - }); - - test('should include SDK installation check instructions', async () => { - const prompt = await getSkillPrompt('doctor'); - - expect(prompt).toContain('ClixSDK'); - expect(prompt).toContain('@clix-so/react-native-sdk'); - expect(prompt).toContain('clix_flutter_sdk'); - }); - - test('should use cwd when projectPath not provided', async () => { - const prompt = await getSkillPrompt('doctor'); - expect(prompt).toContain(`Project path: ${process.cwd()}`); - }); + expect(prompt).toContain('Project path: /tmp/project'); + expect(prompt).toContain('analyzing a mobile project for Clix SDK'); }); - describe('skills from @clix-so/clix-agent-skills package', () => { - test('integration skill should load from package', async () => { - const prompt = await getSkillPrompt('integration', { - projectPath: '/test/project', - platform: 'ios', - }); - - expect(prompt).toContain('Project path: /test/project'); - expect(prompt).toContain('Target platform: ios'); - }); - - test('event-tracking skill should load from package', async () => { - const prompt = await getSkillPrompt('event-tracking', { - projectPath: '/test/project', - }); - - expect(prompt).toContain('Project path: /test/project'); - }); - - test('user-management skill should load from package', async () => { - const prompt = await getSkillPrompt('user-management', { - projectPath: '/test/project', - }); - - expect(prompt).toContain('Project path: /test/project'); + test('builds install prompt in integration phase by default', async () => { + const prompt = await getSkillPrompt('install', { + projectPath: '/tmp/project', + platform: 'ios', + oneShot: true, }); - test('personalization skill should load from package', async () => { - const prompt = await getSkillPrompt('personalization', { - projectPath: '/test/project', - }); - - expect(prompt).toContain('Project path: /test/project'); - }); + expect(prompt).toContain('Project path: /tmp/project'); + expect(prompt).toContain('Target platform: ios'); + expect(prompt).toContain('Install phase: integration'); + expect(prompt).toContain('EXECUTION MODE: AUTONOMOUS'); + }); - test('should default platform to auto-detect', async () => { - const prompt = await getSkillPrompt('integration'); - expect(prompt).toContain('Target platform: auto-detect'); - }); + test('rejects non-local skill types', async () => { + await expect(getSkillPrompt('integration')).rejects.toThrow('Unknown command type'); }); }); describe('executeSkill', () => { - test('should call executor with skill prompt', async () => { - const mockExecutor = createMockExecutorWithResponses([ - { type: 'text', content: 'Executing skill...' }, - { type: 'complete', content: '' }, - ]); - - const messages: AgentMessage[] = []; - for await (const message of executeSkill('doctor', mockExecutor)) { - messages.push(message); - } - - expect(messages.length).toBe(2); - expect(messages[0].type).toBe('text'); - expect(messages[1].type).toBe('complete'); - }); - - test('should pass options to executor', async () => { - const mockExecutor = createMockExecutorWithResponses([ - { type: 'text', content: 'Done' }, - { type: 'complete', content: '' }, - ]); - - const options = { - projectPath: '/custom/path', - platform: 'ios' as const, - }; - - const messages: AgentMessage[] = []; - for await (const message of executeSkill('doctor', mockExecutor, options)) { - messages.push(message); - } - - expect(messages.length).toBe(2); - // Verify executor was called (we can't easily check the exact parameters without more complex mocking) - }); - - test('should yield all messages from executor', async () => { + test('streams all executor messages', async () => { const mockExecutor = createMockExecutorWithResponses([ { type: 'text', content: 'Part 1' }, { type: 'text', content: 'Part 2' }, - { type: 'text', content: 'Part 3' }, { type: 'complete', content: '' }, ]); const messages: AgentMessage[] = []; - for await (const message of executeSkill('integration', mockExecutor)) { + for await (const message of executeSkill('doctor', mockExecutor)) { messages.push(message); } - expect(messages.length).toBe(4); - expect(messages[0].content).toBe('Part 1'); - expect(messages[1].content).toBe('Part 2'); - expect(messages[2].content).toBe('Part 3'); + expect(messages.map((m) => m.content)).toEqual(['Part 1', 'Part 2', '']); }); -}); - -describe('SkillType', () => { - test('should include all expected skill types', () => { - const skillTypes: SkillType[] = [ - 'integration', - 'event-tracking', - 'user-management', - 'personalization', - 'doctor', - ]; - for (const type of skillTypes) { - const skill = getSkillInfo(type); - expect(skill).toBeDefined(); - } - }); -}); + test('passes executor options through', async () => { + let capturedOptions: ExecuteOptions | undefined; + let capturedPrompt = ''; -describe('SkillOptions', () => { - test('should accept all platform types', async () => { - for (const platform of FIXTURES.platforms) { - const prompt = await getSkillPrompt('integration', { platform }); - expect(prompt).toContain(`Target platform: ${platform}`); - } - }); -}); - -describe('preparationContext in install skill', () => { - /** - * These tests verify that preparationContext is properly integrated into the install skill prompt. - * This covers the functionality that was previously in standalone firebase and ios-setup commands. - */ - - test('install skill should include pre-configured setup section when preparationContext is provided', async () => { - const { getSkillPrompt } = await import('../skills'); - const preparationContext = { - projectPath: '/test/project', - config: { - version: 2 as const, - member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, - organization: { id: 'org-1', name: 'Test Org' }, - project: { id: 'project-1', name: 'Test Project', publicKey: 'pk_test_123' }, - linkedAt: '2024-01-01T00:00:00Z', - }, - projectType: { framework: 'react-native' as const, target: 'ios-android' as const }, - firebase: { - needed: true, - configured: true, - androidConfigured: true, - iosConfigured: true, - senderConfigConfigured: true, - projectId: 'my-firebase-project', - }, - ios: { - needed: true, - bundleId: 'com.test.app', - teamId: 'TEAM123', - appGroupId: 'group.com.test.app', - entitlementsConfigured: true, - nseConfigured: false, - }, - apns: { - needed: true, - keyId: 'ABC1234567', - teamId: 'TEAM123', - registeredWithFirebase: true, - }, - missing: ['Notification Service Extension'], - ready: false, - }; - - const prompt = await getSkillPrompt('install', { - projectPath: '/test/project', - preparationContext, + const executor = createMockExecutor({ + execute: mock(async function* ( + prompt: string, + options?: ExecuteOptions, + ): AsyncGenerator { + capturedPrompt = prompt; + capturedOptions = options; + yield { type: 'complete', content: '' }; + }), }); - // Verify pre-configured setup section is included - expect(prompt).toContain('Pre-configured Setup'); - expect(prompt).toContain('Test Project'); - expect(prompt).toContain('Detected project type: react-native (iOS/Android)'); - expect(prompt).toContain('Install Step Verification'); - - // Verify Clix project info - expect(prompt).toContain('Clix Project'); - expect(prompt).toContain('project-1'); - expect(prompt).toContain('pk_test_123'); - - // Verify Firebase info - expect(prompt).toContain('Firebase'); - expect(prompt).toContain('my-firebase-project'); - expect(prompt).toContain('Android (google-services.json)'); - expect(prompt).toContain('iOS (GoogleService-Info.plist)'); - expect(prompt).toContain('Sender Config (App Push): ✓ configured'); - - // Verify APNS info - expect(prompt).toContain('APNS'); - expect(prompt).toContain('Key ID: ABC1234567'); - expect(prompt).toContain('Team ID: TEAM123'); - expect(prompt).toContain('Registered with Firebase: ✓ configured'); - - // Verify iOS info - expect(prompt).toContain('iOS'); - expect(prompt).toContain('com.test.app'); - expect(prompt).toContain('TEAM123'); - expect(prompt).toContain('group.com.test.app'); - expect(prompt).toContain('Entitlements'); - expect(prompt).toContain('NSE'); - - // Verify missing items - expect(prompt).toContain('Missing Setup'); - expect(prompt).toContain('Notification Service Extension'); - }); - - test('install skill should not include iOS section when iOS is not needed', async () => { - const { getSkillPrompt } = await import('../skills'); - const preparationContext = { - projectPath: '/test/project', - config: { - version: 2 as const, - member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, - organization: { id: 'org-1', name: 'Test Org' }, - project: { id: 'project-1', name: 'Android Project' }, - linkedAt: '2024-01-01T00:00:00Z', - }, - projectType: { framework: 'native' as const, target: 'android' as const }, - firebase: { - needed: true, - configured: true, - androidConfigured: true, - iosConfigured: true, - senderConfigConfigured: true, - projectId: 'android-project', - }, - ios: { - needed: false, - bundleId: undefined, - teamId: undefined, - appGroupId: undefined, - entitlementsConfigured: true, - nseConfigured: true, - }, - apns: { - needed: false, - registeredWithFirebase: true, - }, - missing: [], - ready: true, - }; - - const prompt = await getSkillPrompt('install', { - projectPath: '/test/project', - preparationContext, - }); - - // Should have Firebase section - expect(prompt).toContain('Firebase'); - expect(prompt).toContain('android-project'); - - // Should NOT have iOS section (needed=false) - const iosHeaderMatch = prompt.match(/### iOS\n/); - expect(iosHeaderMatch).toBeNull(); - }); - - test('install skill should not include Firebase section when Firebase is not needed', async () => { - const { getSkillPrompt } = await import('../skills'); - const preparationContext = { - projectPath: '/test/project', - config: { - version: 2 as const, - member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, - organization: { id: 'org-1', name: 'Test Org' }, - project: { id: 'project-1', name: 'Unknown Project' }, - linkedAt: '2024-01-01T00:00:00Z', - }, - projectType: { framework: 'unknown' as const, target: 'unknown' as const }, - firebase: { - needed: false, - configured: true, - androidConfigured: true, - iosConfigured: true, - senderConfigConfigured: true, - projectId: undefined, - }, - ios: { - needed: false, - bundleId: undefined, - teamId: undefined, - appGroupId: undefined, - entitlementsConfigured: true, - nseConfigured: true, - }, - apns: { - needed: false, - registeredWithFirebase: true, - }, - missing: [], - ready: true, - }; - - const prompt = await getSkillPrompt('install', { - projectPath: '/test/project', - preparationContext, - }); - - // Should NOT have Firebase section (needed=false) - const firebaseHeaderMatch = prompt.match(/### Firebase\n/); - expect(firebaseHeaderMatch).toBeNull(); - }); - - test('install skill without preparationContext should work normally', async () => { - const { getSkillPrompt } = await import('../skills'); - - const prompt = await getSkillPrompt('install', { - projectPath: '/test/project', - }); - - // Should have basic info but no pre-configured setup section - expect(prompt).toContain('Project path: /test/project'); - // Pre-configured Setup section should not appear without context - expect(prompt).not.toContain('### Clix Project'); - }); - - test('install skill should execute SDK integration prompt content', async () => { - const { getSkillPrompt } = await import('../skills'); - - const prompt = await getSkillPrompt('install', { - projectPath: '/test/project', - }); - - expect(prompt).toContain('# Clix SDK Autonomous Install'); - expect(prompt).toContain('execute SDK integration workflow'); - }); - - test('install skill should include project build phase instructions when requested', async () => { - const { getSkillPrompt } = await import('../skills'); - - const prompt = await getSkillPrompt('install', { - projectPath: '/test/project', - installPhase: 'project-build', - }); - - expect(prompt).toContain('Install phase: project-build'); - expect(prompt).toContain('execute only project build workflow'); - }); - - test('install skill should infer target platform from preparationContext project type', async () => { - const { getSkillPrompt } = await import('../skills'); - - const preparationContext = { - projectPath: '/test/project', - config: { - version: 2 as const, - member: { id: 'member-1', email: 'test@example.com', name: 'Test User' }, - organization: { id: 'org-1', name: 'Test Org' }, - project: { id: 'project-1', name: 'Flutter Project' }, - linkedAt: '2024-01-01T00:00:00Z', - }, - projectType: { framework: 'flutter' as const, target: 'ios-android' as const }, - firebase: { - needed: false, - configured: false, - androidConfigured: false, - iosConfigured: false, - senderConfigConfigured: false, - projectId: undefined, - }, - ios: { - needed: false, - bundleId: undefined, - teamId: undefined, - appGroupId: undefined, - entitlementsConfigured: false, - nseConfigured: false, - }, - apns: { - needed: false, - registeredWithFirebase: false, - }, - missing: [], - ready: true, - }; - - const prompt = await getSkillPrompt('install', { - projectPath: '/test/project', - preparationContext, - }); - - expect(prompt).toContain('Target platform: flutter'); - }); -}); - -describe('error handling', () => { - describe('getSkillPrompt error messages', () => { - test('should return clear error message when skills package is missing', async () => { - // This test verifies that when getSkillPrompt fails, it throws with a clear message - // The actual error would occur in a binary build without @clix-so/clix-agent-skills installed - // Here we verify the diagnose skill (local) works correctly as a baseline - const prompt = await getSkillPrompt('doctor'); - expect(prompt).toContain('Project path:'); - }); - - test('error from missing skill should contain installation instructions', async () => { - // Simulate the expected error message format - const expectedErrorMessage = - 'Skills package not found. Please install it: npm install -g @clix-so/clix-agent-skills'; - - // Verify the error message format matches what we expect in production - expect(expectedErrorMessage).toContain('Skills package not found'); - expect(expectedErrorMessage).toContain('npm install'); - expect(expectedErrorMessage).toContain('@clix-so/clix-agent-skills'); - }); - - test('error from missing skill file should be descriptive', async () => { - // Simulate the expected error message format for missing skill files - const skillFolder = 'integration'; - const expectedErrorMessage = `Failed to read skill file for "${skillFolder}". Ensure @clix-so/clix-agent-skills is installed.`; - - expect(expectedErrorMessage).toContain('Failed to read skill file'); - expect(expectedErrorMessage).toContain(skillFolder); - expect(expectedErrorMessage).toContain('@clix-so/clix-agent-skills'); - }); - }); - - describe('executeSkill error propagation', () => { - test('should propagate errors from executor', async () => { - const errorMessage = 'Test execution error'; - const mockExecutor = createMockExecutorWithResponses([ - { type: 'error', content: errorMessage }, - ]); - - const messages: AgentMessage[] = []; - for await (const message of executeSkill('doctor', mockExecutor)) { - messages.push(message); - } - - expect(messages.length).toBe(1); - expect(messages[0].type).toBe('error'); - expect(messages[0].content).toBe(errorMessage); - }); - - test('should handle executor that throws Error', async () => { - const mockExecutor = createMockExecutorWithResponses([]); - const originalExecute = mockExecutor.execute.bind(mockExecutor); - - // Override execute to throw an Error - // biome-ignore lint/correctness/useYield: Intentionally throws before yielding to test error handling - mockExecutor.execute = async function* () { - throw new Error('Executor threw an error'); - }; - - try { - const messages: AgentMessage[] = []; - for await (const message of executeSkill('doctor', mockExecutor)) { - messages.push(message); - } - // Should not reach here - expect(true).toBe(false); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe('Executor threw an error'); - } - - // Restore original execute - mockExecutor.execute = originalExecute; - }); - - test('should handle executor that throws non-Error', async () => { - const mockExecutor = createMockExecutorWithResponses([]); - - // Override execute to throw a string - // biome-ignore lint/correctness/useYield: Intentionally throws before yielding to test error handling - mockExecutor.execute = async function* () { - throw 'String error thrown'; - }; - - try { - const messages: AgentMessage[] = []; - for await (const message of executeSkill('doctor', mockExecutor)) { - messages.push(message); - } - // Should not reach here - expect(true).toBe(false); - } catch (error) { - expect(error).toBe('String error thrown'); - } - }); - }); -}); - -describe('embedded skills fallback', () => { - /** - * These tests verify that embedded skills are available as a fallback - * when the external @clix-so/clix-agent-skills package is not installed. - * This is critical for the binary distribution to work standalone. - */ - - const SKILL_FOLDER_MAP = { - integration: 'integration', - 'event-tracking': 'event-tracking', - 'user-management': 'user-management', - personalization: 'personalization', - }; - - describe('embedded skills are present', () => { - test('hasEmbeddedSkills should return true', () => { - expect(hasEmbeddedSkills()).toBe(true); - }); - - test('all required skill folders should be embedded', () => { - for (const folder of Object.values(SKILL_FOLDER_MAP)) { - const skill = getEmbeddedSkill(folder); - expect(skill).toBeDefined(); - expect(typeof skill).toBe('string'); - expect(skill?.length).toBeGreaterThan(100); // Skills should have substantial content - } - }); - - test('EMBEDDED_SKILLS should have at least 4 skills', () => { - // At minimum: integration, event-tracking, user-management, personalization - expect(Object.keys(EMBEDDED_SKILLS).length).toBeGreaterThanOrEqual(4); - }); - }); - - describe('embedded skills content validation', () => { - test('integration skill should contain SDK integration instructions', () => { - const skill = getEmbeddedSkill('integration'); - expect(skill).toContain('clix-integration'); - expect(skill).toContain('SDK'); - }); - - test('event-tracking skill should contain trackEvent instructions', () => { - const skill = getEmbeddedSkill('event-tracking'); - expect(skill).toContain('clix-event-tracking'); - expect(skill).toContain('trackEvent'); - }); - - test('user-management skill should contain setUserId instructions', () => { - const skill = getEmbeddedSkill('user-management'); - expect(skill).toContain('clix-user-management'); - expect(skill).toContain('setUserId'); - }); - - test('personalization skill should contain personalization instructions', () => { - const skill = getEmbeddedSkill('personalization'); - expect(skill).toContain('clix-personalization'); - }); - }); - - describe('skill loading matches embedded skills', () => { - /** - * Verify that getSkillPrompt returns content that matches embedded skills. - * In dev environment, external package is preferred, but embedded skills - * should have the same content (from the same source). - */ - test('integration skill prompt should match embedded content structure', async () => { - const prompt = await getSkillPrompt('integration'); - const embedded = getEmbeddedSkill('integration'); - - // Both should contain the same skill marker - expect(embedded).toContain('clix-integration'); - // Prompt includes project path prefix, but should contain skill content - expect(prompt).toContain('Project path:'); - }); - - test('event-tracking skill prompt should match embedded content structure', async () => { - const prompt = await getSkillPrompt('event-tracking'); - const embedded = getEmbeddedSkill('event-tracking'); - - expect(embedded).toContain('clix-event-tracking'); - expect(prompt).toContain('Project path:'); - }); - - test('user-management skill prompt should match embedded content structure', async () => { - const prompt = await getSkillPrompt('user-management'); - const embedded = getEmbeddedSkill('user-management'); - - expect(embedded).toContain('clix-user-management'); - expect(prompt).toContain('Project path:'); - }); - - test('personalization skill prompt should match embedded content structure', async () => { - const prompt = await getSkillPrompt('personalization'); - const embedded = getEmbeddedSkill('personalization'); + for await (const _message of executeSkill('doctor', executor, { + projectPath: '/tmp/custom', + oneShot: true, + })) { + // consume stream + } - expect(embedded).toContain('clix-personalization'); - expect(prompt).toContain('Project path:'); + expect(capturedPrompt).toContain('Project path: /tmp/custom'); + expect(capturedOptions).toMatchObject({ + workingDirectory: '/tmp/custom', }); }); }); diff --git a/src/lib/__tests__/test-utils/fixtures.ts b/src/lib/__tests__/test-utils/fixtures.ts index 01fccbf..7828205 100644 --- a/src/lib/__tests__/test-utils/fixtures.ts +++ b/src/lib/__tests__/test-utils/fixtures.ts @@ -1,4 +1,7 @@ -import type { ConversationMessage } from '../../executor'; +interface ConversationMessage { + role: 'user' | 'assistant'; + content: string; +} /** * Test fixtures for various test scenarios diff --git a/src/lib/__tests__/test-utils/mock-factories.ts b/src/lib/__tests__/test-utils/mock-factories.ts index 9a05391..497367e 100644 --- a/src/lib/__tests__/test-utils/mock-factories.ts +++ b/src/lib/__tests__/test-utils/mock-factories.ts @@ -1,19 +1,11 @@ import { mock } from 'bun:test'; import type { AgentInfo } from '../../agents'; -import type { - AgentExecutor, - AgentMessage, - CompactionResult, - ConversationMessage, -} from '../../executor'; +import type { AgentExecutor, AgentMessage } from '../../executor'; /** - * Creates a mock AgentExecutor for testing + * Creates a mock AgentExecutor for testing. */ export function createMockExecutor(overrides?: Partial): AgentExecutor { - const mockHistory: ConversationMessage[] = []; - let mockSessionId: string | null = null; - return { name: 'mock', isAvailable: mock(async () => true), @@ -21,35 +13,12 @@ export function createMockExecutor(overrides?: Partial): AgentExe yield { type: 'text', content: 'Mock response' }; yield { type: 'complete', content: '' }; }), - clearHistory: mock(() => { - mockHistory.length = 0; - }), - getHistory: mock(() => [...mockHistory]), - setHistory: mock((history: ConversationMessage[]) => { - mockHistory.length = 0; - mockHistory.push(...history); - }), - getSessionId: mock(() => mockSessionId), - setSessionId: mock((id: string | null) => { - mockSessionId = id; - }), - needsCompaction: mock(() => false), - compactHistory: mock( - async (): Promise => ({ - compacted: false, - originalLength: 0, - newLength: 0, - messagesCompacted: 0, - messagesPreserved: 0, - }), - ), - resetSession: mock(() => {}), ...overrides, }; } /** - * Creates a mock AgentInfo object for testing + * Creates a mock AgentInfo object for testing. */ export function createMockAgent(overrides?: Partial): AgentInfo { return { @@ -64,33 +33,7 @@ export function createMockAgent(overrides?: Partial): AgentInfo { } /** - * Creates a single mock ConversationMessage - */ -export function createMockMessage(overrides?: Partial): ConversationMessage { - return { - role: 'user', - content: 'Test message', - ...overrides, - }; -} - -/** - * Creates a mock conversation history with alternating user/assistant messages - * @param count Number of messages to create - * @param contentMultiplier Multiplier for content length (default 100 chars per message) - */ -export function createMockHistory( - count: number, - contentMultiplier: number = 100, -): ConversationMessage[] { - return Array.from({ length: count }, (_, i) => ({ - role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant', - content: `Message ${i + 1} `.repeat(Math.ceil(contentMultiplier / 10)), - })); -} - -/** - * Creates an executor that yields specific messages for testing + * Creates an executor that yields specific messages for testing. */ export function createMockExecutorWithResponses(responses: AgentMessage[]): AgentExecutor { return createMockExecutor({ diff --git a/src/lib/commands/__tests__/registry.test.ts b/src/lib/commands/__tests__/registry.test.ts deleted file mode 100644 index e823367..0000000 --- a/src/lib/commands/__tests__/registry.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { - clearCommandCache, - generateHelpText, - getCommand, - getCommands, - hasCommand, -} from '../registry'; - -describe('Command Registry', () => { - beforeEach(() => { - clearCommandCache(); - }); - - afterEach(() => { - clearCommandCache(); - }); - - describe('getCommands', () => { - test('should return all enabled commands', () => { - const commands = getCommands(); - expect(commands.length).toBeGreaterThan(0); - expect(commands.every((c) => c.isEnabled)).toBe(true); - }); - - test('should return cached commands on subsequent calls', () => { - const first = getCommands(); - const second = getCommands(); - expect(first).toBe(second); // Same reference - }); - }); - - describe('getCommand', () => { - test('should find command by name', () => { - const command = getCommand('help'); - expect(command).toBeDefined(); - expect(command?.name).toBe('help'); - }); - - test('should find command by alias', () => { - const command = getCommand('?'); - expect(command).toBeDefined(); - expect(command?.name).toBe('help'); - }); - - test('should find command with slash prefix', () => { - const command = getCommand('/help'); - expect(command).toBeDefined(); - expect(command?.name).toBe('help'); - }); - - test('should be case-insensitive', () => { - const command = getCommand('HELP'); - expect(command).toBeDefined(); - expect(command?.name).toBe('help'); - }); - - test('should return undefined for non-existent command', () => { - const command = getCommand('nonexistent'); - expect(command).toBeUndefined(); - }); - }); - - describe('hasCommand', () => { - test('should return true for existing command', () => { - expect(hasCommand('help')).toBe(true); - }); - - test('should return true for command alias', () => { - expect(hasCommand('?')).toBe(true); - }); - - test('should return false for non-existent command', () => { - expect(hasCommand('nonexistent')).toBe(false); - }); - }); - - describe('clearCommandCache', () => { - test('should clear the cache', () => { - const first = getCommands(); - clearCommandCache(); - const second = getCommands(); - expect(first).not.toBe(second); // Different references - }); - }); - - describe('generateHelpText', () => { - test('should generate help text with all visible commands', () => { - const helpText = generateHelpText(); - expect(helpText).toContain('Available commands:'); - expect(helpText).toContain('/help'); - expect(helpText).toContain('/new'); - expect(helpText).toContain('/clear'); - expect(helpText).toContain('/agent'); - }); - - test('should include skill commands', () => { - const helpText = generateHelpText(); - expect(helpText).toContain('Skills:'); - expect(helpText).toContain('/integration'); - expect(helpText).toContain('/event-tracking'); - }); - - test('should include system commands', () => { - const helpText = generateHelpText(); - expect(helpText).toContain('System:'); - expect(helpText).toContain('/exit'); - }); - }); - - describe('built-in commands', () => { - test('should have help command with aliases', () => { - const help = getCommand('help'); - expect(help?.aliases).toContain('?'); - expect(help?.aliases).toContain('h'); - }); - - test('should have compact command with alias', () => { - const compact = getCommand('compact'); - expect(compact?.aliases).toContain('c'); - }); - - test('should have agent command with alias', () => { - const agent = getCommand('agent'); - expect(agent?.aliases).toContain('a'); - }); - - test('should have exit command with aliases', () => { - const exit = getCommand('exit'); - expect(exit?.aliases).toContain('quit'); - expect(exit?.aliases).toContain('q'); - }); - - test('should have install-mcp command with alias', () => { - const installMcp = getCommand('install-mcp'); - expect(installMcp?.aliases).toContain('mcp'); - }); - - test('should have new command with clear alias', () => { - const cmd = getCommand('new'); - expect(cmd?.aliases).toContain('clear'); - }); - - test('should have transfer command with alias', () => { - const transfer = getCommand('transfer'); - expect(transfer?.aliases).toContain('t'); - }); - }); - - describe('skill commands', () => { - test('should have integration command', () => { - const integration = getCommand('integration'); - expect(integration).toBeDefined(); - expect(integration?.name).toBe('integration'); - }); - - test('should have event-tracking command', () => { - const eventTracking = getCommand('event-tracking'); - expect(eventTracking).toBeDefined(); - }); - - test('should have user-management command', () => { - const userManagement = getCommand('user-management'); - expect(userManagement).toBeDefined(); - }); - - test('should have personalization command', () => { - const personalization = getCommand('personalization'); - expect(personalization).toBeDefined(); - }); - - test('should have doctor command', () => { - const doctor = getCommand('doctor'); - expect(doctor).toBeDefined(); - }); - - test('should have api-triggered-campaigns command (dynamically discovered)', () => { - // This tests dynamic command registration from @clix-so/clix-agent-skills - const apiCampaign = getCommand('api-triggered-campaigns'); - expect(apiCampaign).toBeDefined(); - expect(apiCampaign?.name).toBe('api-triggered-campaigns'); - }); - - test('command names should match skill command names from package', () => { - // Commands are derived from skill names by removing 'clix-' prefix - // e.g., 'clix-integration' -> 'integration' - // This ensures CLI commands always match the skills package - const commands = getCommands(); - const skillCommands = commands.filter( - (c) => - c.type === 'local' && - ![ - 'help', - 'new', - 'exit', - 'agent', - 'compact', - 'transfer', - 'debug', - 'install-mcp', - 'update', - 'doctor', - ].includes(c.name), - ); - - for (const cmd of skillCommands) { - // Command name should NOT have 'clix-' prefix - expect(cmd.name).not.toMatch(/^clix-/); - // Command name should be lowercase with hyphens - expect(cmd.name).toMatch(/^[a-z][a-z0-9-]*$/); - } - }); - }); - - describe('dynamic command registration', () => { - test('commands are generated from EMBEDDED_SKILL_METADATA', async () => { - const { EMBEDDED_SKILL_METADATA } = await import('../../embedded-skills'); - const commands = getCommands(); - - // Each embedded skill should have a corresponding command - for (const meta of EMBEDDED_SKILL_METADATA) { - const cmd = commands.find((c) => c.name === meta.commandName); - expect(cmd).toBeDefined(); - expect(cmd?.description).toBe(meta.shortDescription || meta.displayName); - } - }); - - test('new skills automatically get commands without code changes', async () => { - // This validates the core requirement: when new skills are added to - // @clix-so/clix-agent-skills, they automatically get commands - const { EMBEDDED_SKILL_METADATA } = await import('../../embedded-skills'); - - // Get the count of embedded skills - const embeddedSkillCount = EMBEDDED_SKILL_METADATA.length; - - // Get skill commands (excluding local skills like doctor) - const commands = getCommands(); - const skillCommands = commands.filter((c) => - EMBEDDED_SKILL_METADATA.some((m) => m.commandName === c.name), - ); - - // The number of skill commands should match embedded skills - expect(skillCommands.length).toBe(embeddedSkillCount); - }); - }); -}); diff --git a/src/lib/commands/__tests__/types.test.ts b/src/lib/commands/__tests__/types.test.ts deleted file mode 100644 index e125fbf..0000000 --- a/src/lib/commands/__tests__/types.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import type { Command } from '../types'; -import { isLocalCommand, isLocalJSXCommand, isPromptCommand } from '../types'; - -describe('Command Types', () => { - describe('type guards', () => { - const localCommand: Command = { - type: 'local', - name: 'test-local', - description: 'Test local command', - isEnabled: true, - isHidden: false, - userFacingName: () => '/test-local', - call: async () => ({ success: true }), - }; - - const localJSXCommand: Command = { - type: 'local-jsx', - name: 'test-jsx', - description: 'Test JSX command', - isEnabled: true, - isHidden: false, - userFacingName: () => '/test-jsx', - call: async () => null, - }; - - const promptCommand: Command = { - type: 'prompt', - name: 'test-prompt', - description: 'Test prompt command', - isEnabled: true, - isHidden: false, - progressMessage: 'Processing...', - userFacingName: () => '/test-prompt', - getPromptForCommand: async () => [{ role: 'user', content: 'test' }], - }; - - test('isLocalCommand should identify local commands', () => { - expect(isLocalCommand(localCommand)).toBe(true); - expect(isLocalCommand(localJSXCommand)).toBe(false); - expect(isLocalCommand(promptCommand)).toBe(false); - }); - - test('isLocalJSXCommand should identify JSX commands', () => { - expect(isLocalJSXCommand(localCommand)).toBe(false); - expect(isLocalJSXCommand(localJSXCommand)).toBe(true); - expect(isLocalJSXCommand(promptCommand)).toBe(false); - }); - - test('isPromptCommand should identify prompt commands', () => { - expect(isPromptCommand(localCommand)).toBe(false); - expect(isPromptCommand(localJSXCommand)).toBe(false); - expect(isPromptCommand(promptCommand)).toBe(true); - }); - }); - - describe('command properties', () => { - test('LocalCommand should have call method', () => { - const command: Command = { - type: 'local', - name: 'test', - description: 'Test', - isEnabled: true, - isHidden: false, - userFacingName: () => '/test', - call: async () => ({ success: true, message: 'Done' }), - }; - - expect(command.type).toBe('local'); - expect(typeof command.call).toBe('function'); - }); - - test('LocalJSXCommand should have call method with onDone callback', () => { - const command: Command = { - type: 'local-jsx', - name: 'test', - description: 'Test', - isEnabled: true, - isHidden: false, - userFacingName: () => '/test', - call: async (onDone) => { - onDone('result'); - return null; - }, - }; - - expect(command.type).toBe('local-jsx'); - expect(typeof command.call).toBe('function'); - }); - - test('PromptCommand should have getPromptForCommand method', () => { - const command: Command = { - type: 'prompt', - name: 'test', - description: 'Test', - isEnabled: true, - isHidden: false, - progressMessage: 'Processing...', - userFacingName: () => '/test', - getPromptForCommand: async (args) => [{ role: 'user', content: args }], - }; - - expect(command.type).toBe('prompt'); - expect(typeof command.getPromptForCommand).toBe('function'); - expect(command.progressMessage).toBe('Processing...'); - }); - }); - - describe('optional properties', () => { - test('command can have aliases', () => { - const command: Command = { - type: 'local', - name: 'test', - description: 'Test', - isEnabled: true, - isHidden: false, - aliases: ['t', 'tst'], - userFacingName: () => '/test', - call: async () => ({ success: true }), - }; - - expect(command.aliases).toEqual(['t', 'tst']); - }); - - test('command can be hidden', () => { - const command: Command = { - type: 'local', - name: 'secret', - description: 'Secret command', - isEnabled: true, - isHidden: true, - userFacingName: () => '/secret', - call: async () => ({ success: true }), - }; - - expect(command.isHidden).toBe(true); - }); - - test('command can be disabled', () => { - const command: Command = { - type: 'local', - name: 'disabled', - description: 'Disabled command', - isEnabled: false, - isHidden: false, - userFacingName: () => '/disabled', - call: async () => ({ success: true }), - }; - - expect(command.isEnabled).toBe(false); - }); - }); -}); diff --git a/src/lib/commands/agent.ts b/src/lib/commands/agent.ts deleted file mode 100644 index 89f9cfd..0000000 --- a/src/lib/commands/agent.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Agent command - lists or switches AI agents. - * - * @module commands/agent - */ - -import type { Command, CommandContext } from './types'; - -/** - * Agent command implementation. - * Lists available agents or switches to a specified agent. - */ -export const agentCommand: Command = { - type: 'local', - name: 'agent', - description: 'List or switch agents', - isEnabled: true, - isHidden: false, - aliases: ['a'], - - userFacingName() { - return '/agent'; - }, - - async call(context: CommandContext) { - const agentName = context.args[0]; - if (agentName) { - return { success: true, message: `Switching to agent: ${agentName}` }; - } - // No agent specified, should show selector - return { success: true }; - }, -}; diff --git a/src/lib/commands/compact.ts b/src/lib/commands/compact.ts deleted file mode 100644 index 7db72bb..0000000 --- a/src/lib/commands/compact.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Compact command - compresses conversation history. - * - * @module commands/compact - */ - -import type { Command, CommandContext } from './types'; - -/** - * Compact command implementation. - * Compresses the conversation history to save context window space. - */ -export const compactCommand: Command = { - type: 'local', - name: 'compact', - description: 'Compress conversation history', - isEnabled: true, - isHidden: false, - aliases: ['c'], - - userFacingName() { - return '/compact'; - }, - - async call(context: CommandContext) { - const force = context.args[0] === 'force'; - // The actual compaction is handled by the command handler - return { - success: true, - message: force ? 'Force compacting history...' : 'Compacting history...', - }; - }, -}; diff --git a/src/lib/commands/debug.ts b/src/lib/commands/debug.ts deleted file mode 100644 index 9901dbd..0000000 --- a/src/lib/commands/debug.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Debug command - interactive debugging assistant. - * - * @module commands/debug - */ - -import type { Command } from './types'; - -/** - * Debug command implementation. - * Opens an interactive debugging prompt for troubleshooting. - */ -export const debugCommand: Command = { - type: 'local', - name: 'debug', - description: 'Interactive debugging assistant', - isEnabled: true, - isHidden: false, - - userFacingName() { - return '/debug'; - }, - - async call() { - // The actual debug prompt is shown by the command handler - return { success: true }; - }, -}; diff --git a/src/lib/commands/exit.ts b/src/lib/commands/exit.ts deleted file mode 100644 index 85923dc..0000000 --- a/src/lib/commands/exit.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Exit command - exits the chat application. - * - * @module commands/exit - */ - -import type { Command } from './types'; - -/** - * Exit command implementation. - * Exits the chat application. - */ -export const exitCommand: Command = { - type: 'local', - name: 'exit', - description: 'Exit the chat', - isEnabled: true, - isHidden: false, - aliases: ['quit', 'q'], - - userFacingName() { - return '/exit'; - }, - - async call() { - // The actual exit is handled by the command handler - return { success: true }; - }, -}; diff --git a/src/lib/commands/help.ts b/src/lib/commands/help.ts deleted file mode 100644 index 91ffb9c..0000000 --- a/src/lib/commands/help.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Help command - displays available commands. - * - * @module commands/help - */ - -import type { Command } from './types'; - -/** - * Help command implementation. - * Displays a list of available commands with descriptions. - */ -export const helpCommand: Command = { - type: 'local', - name: 'help', - description: 'Show available commands', - isEnabled: true, - isHidden: false, - aliases: ['?', 'h'], - - userFacingName() { - return '/help'; - }, - - async call() { - // The actual help text generation is handled by the command handler - // which can access the full command registry - return { success: true }; - }, -}; diff --git a/src/lib/commands/index.ts b/src/lib/commands/index.ts deleted file mode 100644 index 9352c9b..0000000 --- a/src/lib/commands/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Command system for slash commands. - * - * Provides a registry-based approach to managing commands with support for: - * - Local commands (return string results) - * - Local JSX commands (return React components) - * - Prompt commands (send prompts to AI) - * - * @module commands - * - * @example - * ```typescript - * import { getCommands, getCommand, hasCommand } from './commands'; - * - * // Get all enabled commands - * const commands = getCommands(); - * - * // Find a command by name or alias - * const helpCmd = getCommand('help'); - * const helpCmd2 = getCommand('?'); // alias - * - * // Check if a command exists - * if (hasCommand('new')) { - * // ... - * } - * ``` - */ - -export { agentCommand } from './agent'; -// Individual commands (for direct access if needed) -export { compactCommand } from './compact'; -export { debugCommand } from './debug'; -export { exitCommand } from './exit'; -export { helpCommand } from './help'; -export { installMcpCommand } from './install-mcp'; -export { newCommand } from './new'; -// Registry -export { - clearCommandCache, - generateHelpText, - getCommand, - getCommands, - hasCommand, -} from './registry'; -export { resumeCommand } from './resume'; -export { skillCommands } from './skills'; -export { transferCommand } from './transfer'; -// Types -export type { - Command, - CommandBase, - CommandContext, - CommandDoneCallback, - CommandResult, - LocalCommand, - LocalJSXCommand, - PromptCommand, -} from './types'; -export { isLocalCommand, isLocalJSXCommand, isPromptCommand } from './types'; diff --git a/src/lib/commands/install-mcp.ts b/src/lib/commands/install-mcp.ts deleted file mode 100644 index ef03b51..0000000 --- a/src/lib/commands/install-mcp.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Install MCP command - installs the Clix MCP Server. - * - * @module commands/install-mcp - */ - -import type { Command } from './types'; - -/** - * Install MCP command implementation. - * Opens the MCP server installation selector. - */ -export const installMcpCommand: Command = { - type: 'local', - name: 'install-mcp', - description: 'Install Clix MCP Server', - isEnabled: true, - isHidden: false, - aliases: ['mcp'], - - userFacingName() { - return '/install-mcp'; - }, - - async call() { - // The actual MCP installer is shown by the command handler - return { success: true }; - }, -}; diff --git a/src/lib/commands/login.tsx b/src/lib/commands/login.tsx deleted file mode 100644 index a9c93f0..0000000 --- a/src/lib/commands/login.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Login slash command definition for interactive mode. - * - * @module commands/login - */ -import type { ReactNode } from 'react'; -import { LoginUI } from '@/ui/LoginUI'; -import type { Command, CommandDoneCallback } from './types'; - -export const loginCommand: Command = { - type: 'local-jsx', - name: 'login', - description: 'Log in to Clix via browser', - isEnabled: true, - isHidden: false, - - userFacingName() { - return '/login'; - }, - - async call(onDone: CommandDoneCallback): Promise { - return ( - { - const expiresAt = new Date(credentials.expiresAt).toLocaleString(); - onDone(`Login successful. Token expires at ${expiresAt}`); - }} - onError={(error) => { - onDone(`Login failed: ${error.message}`); - }} - /> - ); - }, -}; diff --git a/src/lib/commands/logout.tsx b/src/lib/commands/logout.tsx deleted file mode 100644 index 52b42f1..0000000 --- a/src/lib/commands/logout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Logout slash command definition for interactive mode. - * - * @module commands/logout - */ -import type { ReactNode } from 'react'; -import { LogoutUI } from '@/ui/LogoutUI'; -import type { Command, CommandDoneCallback } from './types'; - -export const logoutCommand: Command = { - type: 'local-jsx', - name: 'logout', - description: 'Log out from Clix', - isEnabled: true, - isHidden: false, - - userFacingName() { - return '/logout'; - }, - - async call(onDone: CommandDoneCallback): Promise { - return ( - { - if (success) { - onDone('Successfully logged out'); - } else { - onDone('Logout failed'); - } - }} - /> - ); - }, -}; diff --git a/src/lib/commands/new.ts b/src/lib/commands/new.ts deleted file mode 100644 index 18c0b11..0000000 --- a/src/lib/commands/new.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * New command - starts a new chat session. - * - * `/clear` is supported as an alias for backward compatibility. - */ - -import type { Command } from './types'; - -export const newCommand: Command = { - type: 'local', - name: 'new', - description: 'Start a new session', - isEnabled: true, - isHidden: false, - aliases: ['clear'], - - userFacingName() { - return '/new'; - }, - - async call() { - // The actual clearing/reset is handled by the command handler. - return { success: true }; - }, -}; diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts deleted file mode 100644 index ee5415a..0000000 --- a/src/lib/commands/registry.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Command registry for managing and looking up commands. - * - * @module commands/registry - */ - -import { getAvailableSkills } from '../skills'; -import { agentCommand } from './agent'; -import { compactCommand } from './compact'; -import { debugCommand } from './debug'; -import { exitCommand } from './exit'; -import { helpCommand } from './help'; -import { installMcpCommand } from './install-mcp'; -import { loginCommand } from './login'; -import { logoutCommand } from './logout'; -import { newCommand } from './new'; -import { resumeCommand } from './resume'; -import { skillCommands } from './skills'; -import { transferCommand } from './transfer'; -import type { Command } from './types'; -import { uninstallCommand } from './uninstall'; -import { updateCommand } from './update'; -import { whoamiCommand } from './whoami'; - -/** - * Built-in commands. - */ -const BUILT_IN_COMMANDS: Command[] = [ - // System commands - helpCommand, - newCommand, - compactCommand, - agentCommand, - debugCommand, - transferCommand, - resumeCommand, - installMcpCommand, - uninstallCommand, - updateCommand, - exitCommand, - // Auth commands - loginCommand, - logoutCommand, - whoamiCommand, - // Skill commands - ...skillCommands, -]; - -/** - * Command cache for performance. - */ -let commandsCache: Command[] | null = null; - -/** - * Get all enabled commands. - * Results are cached for performance. - */ -export function getCommands(): Command[] { - if (commandsCache) { - return commandsCache; - } - commandsCache = BUILT_IN_COMMANDS.filter((c) => c.isEnabled); - return commandsCache; -} - -/** - * Find a command by name or alias. - * @param name - Command name or alias (with or without slash) - * @param commands - Command list to search (defaults to all commands) - * @returns The matching command or undefined - */ -export function getCommand(name: string, commands?: Command[]): Command | undefined { - const normalized = name.toLowerCase().replace(/^\//, ''); - const searchList = commands ?? getCommands(); - return searchList.find((c) => c.name === normalized || c.aliases?.includes(normalized)); -} - -/** - * Check if a command exists. - * @param name - Command name or alias - * @param commands - Command list to search (defaults to all commands) - * @returns True if the command exists - */ -export function hasCommand(name: string, commands?: Command[]): boolean { - return getCommand(name, commands) !== undefined; -} - -/** - * Clear the command cache. - * Call this if commands are added/removed dynamically. - */ -export function clearCommandCache(): void { - commandsCache = null; -} - -/** - * Generate help text for all visible commands. - * @returns Formatted help text - */ -export function generateHelpText(): string { - const commands = getCommands().filter((c) => !c.isHidden); - - // Group commands by category - dynamically from available skills - const skillNames = new Set(getAvailableSkills().map((s) => s.type)); - const skills = commands.filter((c) => skillNames.has(c.name)); - const system = commands.filter((c) => !skillNames.has(c.name)); - - const formatCommand = (cmd: Command): string => { - const aliasStr = cmd.aliases?.length ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : ''; - return `/${cmd.name}${aliasStr} - ${cmd.description}`; - }; - - const lines = ['Available commands:', '', 'Skills:']; - for (const cmd of skills) { - lines.push(formatCommand(cmd)); - } - - lines.push('', 'System:'); - for (const cmd of system) { - lines.push(formatCommand(cmd)); - } - - return lines.join('\n'); -} diff --git a/src/lib/commands/resume.ts b/src/lib/commands/resume.ts deleted file mode 100644 index 1958002..0000000 --- a/src/lib/commands/resume.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Resume command - resume a previous chat session. - */ - -import type { Command, CommandContext } from './types'; - -export const resumeCommand: Command = { - type: 'local', - name: 'resume', - description: 'Resume a previous chat session', - isEnabled: true, - isHidden: false, - - userFacingName() { - return '/resume'; - }, - - async call(_context: CommandContext) { - // The actual resume UI is handled by the chat command handler. - return { success: true }; - }, -}; diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts deleted file mode 100644 index 3954540..0000000 --- a/src/lib/commands/skills.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Skill commands - dynamically generated from embedded skills. - * - * Commands are generated at runtime from @clix-so/clix-agent-skills metadata, - * allowing new skills to be added without modifying this file. - * - * @module commands/skills - */ - -import { EMBEDDED_SKILL_METADATA, type SkillMetadata } from '../embedded-skills'; -import type { SkillType } from '../skills'; -import type { Command } from './types'; - -/** - * Create a skill command from metadata. - */ -function createSkillCommand(meta: SkillMetadata): Command { - return { - type: 'local', - name: meta.commandName, - description: meta.shortDescription || meta.displayName, - isEnabled: true, - isHidden: false, - - userFacingName() { - return `/${meta.commandName}`; - }, - - async call() { - // The actual skill execution is handled by the command handler - // This just signals which skill to execute - return { success: true, message: meta.commandName as SkillType }; - }, - }; -} - -/** - * Create a local skill command (not from package). - */ -function createLocalSkillCommand( - name: string, - displayName: string, - skillType: SkillType, - aliases?: string[], -): Command { - return { - type: 'local', - name, - description: displayName, - isEnabled: true, - isHidden: false, - aliases, - - userFacingName() { - return `/${name}`; - }, - - async call() { - return { success: true, message: skillType }; - }, - }; -} - -/** - * Generate skill commands from embedded metadata. - */ -function generateSkillCommands(): Command[] { - const commands: Command[] = []; - - // Generate commands from embedded skill metadata - for (const meta of EMBEDDED_SKILL_METADATA) { - commands.push(createSkillCommand(meta)); - } - - // Add local skills (not from package) - commands.push( - createLocalSkillCommand( - 'install', - 'Autonomous SDK integration with automatic file modifications', - 'install', - ), - ); - commands.push(createLocalSkillCommand('doctor', 'Check SDK integration status', 'doctor')); - - return commands; -} - -/** - * All skill commands - dynamically generated from embedded skills. - */ -export const skillCommands: Command[] = generateSkillCommands(); - -/** - * Get a skill command by name. - */ -export function getSkillCommand(name: string): Command | undefined { - return skillCommands.find((cmd) => cmd.name === name); -} - -/** - * Check if a command name is a skill command. - */ -export function isSkillCommand(name: string): boolean { - return getSkillCommand(name) !== undefined; -} - -// Export individual commands for backward compatibility -export const integrationCommand = skillCommands.find((c) => c.name === 'integration'); -export const eventTrackingCommand = skillCommands.find((c) => c.name === 'event-tracking'); -export const userManagementCommand = skillCommands.find((c) => c.name === 'user-management'); -export const personalizationCommand = skillCommands.find((c) => c.name === 'personalization'); -export const apiTriggeredCampaignsCommand = skillCommands.find( - (c) => c.name === 'api-triggered-campaigns', -); -export const doctorCommand = skillCommands.find((c) => c.name === 'doctor'); diff --git a/src/lib/commands/transfer.ts b/src/lib/commands/transfer.ts deleted file mode 100644 index ffce556..0000000 --- a/src/lib/commands/transfer.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Transfer command - transfers conversation to another agent CLI. - * - * @module commands/transfer - */ - -import type { Command, CommandContext } from './types'; - -/** - * Transfer command implementation. - * Transfers the current conversation to another agent's CLI. - */ -export const transferCommand: Command = { - type: 'local', - name: 'transfer', - description: 'Transfer to agent CLI', - isEnabled: true, - isHidden: false, - aliases: ['t'], - - userFacingName() { - return '/transfer'; - }, - - async call(context: CommandContext) { - const targetAgent = context.args[0]; - if (targetAgent) { - return { success: true, message: `Transferring to: ${targetAgent}` }; - } - // No agent specified, should show selector - return { success: true }; - }, -}; diff --git a/src/lib/commands/types.ts b/src/lib/commands/types.ts deleted file mode 100644 index 001716e..0000000 --- a/src/lib/commands/types.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Command system types. - * - * Commands are organized into three types: - * - LocalCommand: Returns a string result (e.g., /new, /compact) - * - LocalJSXCommand: Returns a React component (e.g., /help, /agent) - * - PromptCommand: Sends a prompt to the AI (e.g., skills) - * - * @module commands/types - */ - -import type { ReactNode } from 'react'; - -/** - * Context passed to command handlers. - */ -export interface CommandContext { - /** Arguments passed to the command */ - args: string[]; - /** Abort controller for cancellation */ - abortController?: AbortController; - /** Additional options */ - options?: Record; -} - -/** - * Result of command execution. - */ -export interface CommandResult { - /** Success status */ - success: boolean; - /** Result message */ - message?: string; - /** Error message if failed */ - error?: string; -} - -/** - * Callback for JSX commands to signal completion. - */ -export type CommandDoneCallback = (result?: string) => void; - -/** - * Local command that returns a string result. - * Used for simple commands like /new, /compact. - */ -export interface LocalCommand { - type: 'local'; - /** - * Execute the command. - * @param context - Command context with args - * @returns Command result or void - */ - call(context: CommandContext): Promise; -} - -/** - * Local command that renders a React component. - * Used for interactive commands like /help, /agent, /debug. - */ -export interface LocalJSXCommand { - type: 'local-jsx'; - /** - * Execute the command and return a React component. - * @param onDone - Callback to signal command completion - * @param context - Command context with args - * @returns React component to render - */ - call(onDone: CommandDoneCallback, context: CommandContext): Promise; -} - -/** - * Command that sends a prompt to the AI. - * Used for skill-based commands. - */ -export interface PromptCommand { - type: 'prompt'; - /** Message shown while executing */ - progressMessage: string; - /** Named arguments for the command */ - argNames?: string[]; - /** - * Generate the prompt to send to the AI. - * @param args - Command arguments as string - * @returns Messages to send - */ - getPromptForCommand(args: string): Promise>; -} - -/** - * Base command properties shared by all command types. - */ -export interface CommandBase { - /** Unique command name (without slash) */ - name: string; - /** Human-readable description */ - description: string; - /** Whether the command is currently enabled */ - isEnabled: boolean; - /** Whether to hide from help listing */ - isHidden: boolean; - /** Alternative names for the command */ - aliases?: string[]; - /** - * Get the user-facing name for display. - * @returns Display name with or without slash - */ - userFacingName(): string; -} - -/** - * Union type for all command types. - */ -export type Command = CommandBase & (LocalCommand | LocalJSXCommand | PromptCommand); - -/** - * Type guard to check if a command is a LocalCommand. - */ -export function isLocalCommand(command: Command): command is CommandBase & LocalCommand { - return command.type === 'local'; -} - -/** - * Type guard to check if a command is a LocalJSXCommand. - */ -export function isLocalJSXCommand(command: Command): command is CommandBase & LocalJSXCommand { - return command.type === 'local-jsx'; -} - -/** - * Type guard to check if a command is a PromptCommand. - */ -export function isPromptCommand(command: Command): command is CommandBase & PromptCommand { - return command.type === 'prompt'; -} diff --git a/src/lib/commands/uninstall.ts b/src/lib/commands/uninstall.ts deleted file mode 100644 index e765e57..0000000 --- a/src/lib/commands/uninstall.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Uninstall command - removes Clix CLI from the system. - * - * @module commands/uninstall - */ - -import type { Command } from './types'; - -/** - * Uninstall command implementation. - * In interactive mode, directs users to use the CLI command for full options. - */ -export const uninstallCommand: Command = { - type: 'local', - name: 'uninstall', - description: 'Uninstall Clix CLI from your system', - isEnabled: true, - isHidden: true, - - userFacingName() { - return '/uninstall'; - }, - - async call() { - return { - success: true, - message: - 'To uninstall Clix CLI, run `clix uninstall` from the terminal.\n\n' + - 'Available options:\n' + - ' --keep-config Keep configuration files\n' + - ' --keep-state Keep state/session files\n' + - ' --dry-run Preview changes without removing\n' + - ' --force Skip confirmation prompt', - }; - }, -}; diff --git a/src/lib/commands/update.ts b/src/lib/commands/update.ts deleted file mode 100644 index c6f85c2..0000000 --- a/src/lib/commands/update.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Update command - checks for and executes updates. - * - * @module commands/update - */ - -import type { Command } from './types'; - -/** - * Update command implementation. - * Directs user to CLI for actual update execution. - */ -export const updateCommand: Command = { - type: 'local', - name: 'update', - description: 'Check for and apply available updates', - isEnabled: true, - isHidden: false, - aliases: ['upgrade'], - - userFacingName() { - return '/update'; - }, - - async call() { - return { - success: true, - message: - 'To update Clix CLI, run `clix update` from the terminal.\n\n' + - 'Available options:\n' + - ' --dry-run Preview update without executing\n' + - ' --force Skip confirmation prompt', - }; - }, -}; diff --git a/src/lib/commands/whoami.tsx b/src/lib/commands/whoami.tsx deleted file mode 100644 index c545087..0000000 --- a/src/lib/commands/whoami.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Whoami slash command definition for interactive mode. - * - * @module commands/whoami - */ -import type { ReactNode } from 'react'; -import { WhoamiUI } from '@/ui/WhoamiUI'; -import type { Command, CommandDoneCallback } from './types'; - -export const whoamiCommand: Command = { - type: 'local-jsx', - name: 'whoami', - description: 'Show current logged-in user', - isEnabled: true, - isHidden: false, - - userFacingName() { - return '/whoami'; - }, - - async call(onDone: CommandDoneCallback): Promise { - return ( - { - if (result.status === 'ok') { - onDone(`Logged in as ${result.member.name} (${result.member.email})`); - } else if (result.status === 'not_logged_in') { - onDone('Not logged in. Run /login to authenticate.'); - } else { - onDone(`Error: ${result.message}`); - } - }} - /> - ); - }, -}; diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index f192149..4b45541 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -1,1090 +1,27 @@ /** - * Auto-generated file containing embedded skill prompts and metadata. + * Auto-generated file containing embedded command prompts. * DO NOT EDIT MANUALLY - regenerate with: bun scripts/embed-skills.ts * - * These are embedded at build time from @clix-so/clix-agent-skills package - * to allow the binary to work without requiring the package to be installed. + * Only local command prompts are embedded. */ /** - * Skill metadata interface. + * Skill metadata interface kept for backward compatibility. */ export interface SkillMetadata { - /** Skill folder name (e.g., 'integration', 'event-tracking') */ folder: string; - /** Skill name from frontmatter (e.g., 'clix-integration') */ name: string; - /** Command name derived from name by removing 'clix-' prefix */ commandName: string; - /** Display name from frontmatter (e.g., 'SDK Integration') */ displayName: string; - /** Short description for command menus */ shortDescription: string; - /** Full description from frontmatter (for agent invocation) */ description: string; - /** Whether the skill can be invoked by users */ userInvocable: boolean; } /** - * Embedded skill content by folder name. + * Embedded prompt content by folder name. */ export const EMBEDDED_SKILLS: Record = { - personalization: `--- -name: clix-personalization -display-name: Personalization -short-description: Personalization templates -description: - Helps developers author and debug Clix personalization templates - (Liquid-style) for message content, deep links/URLs, and audience targeting. - Use when the user mentions personalization variables, Liquid, templates, - conditional logic, loops, filters, deep links, message logs, or when the user - types \`clix-personalization\`. -user-invocable: true ---- - -# Clix Personalization - -Use this skill to help developers write **personalized Clix campaigns** using -template-based variables and light logic in: - -- **Message content** (title, body, subtitle) -- **Links** (dynamic URL or deep link params) -- **Audience targeting** (conditional include/exclude rules) - -## What the official docs guarantee (high-signal) - -- **Data namespaces**: - - \`user.*\` (user traits/properties) - - \`event.*\` (event payload properties) - - \`trigger.*\` (custom properties passed via API-trigger) - - Device/system vars: \`device.id\`, \`device.platform\`, \`device.locale\`, - \`device.language\`, \`device.timezone\`, plus \`user.id\`, \`event.name\` -- **Missing variables** render as an **empty string**. -- **Output**: print values with \`{{ ... }}\`. -- **Conditionals**: \`{% if %}\`, \`{% else %}\`, \`{% endif %}\` with operators - \`== != > < >= <= and or not\`. Invalid conditions evaluate to \`false\`. -- **Loops**: \`{% for x in y %}\` / \`{% endfor %}\` (use guards for empty lists). -- **Filters**: \`upcase\`, \`downcase\`, \`capitalize\`, \`default\`, \`join\`, \`split\`, - \`escape\`, \`strip\`, \`replace\` (chain with \`|\`). -- **Errors**: template rendering errors show up in **Message Logs**. - -## MCP-first (source of truth) - -If the Clix MCP tools are available, treat them as the source of truth: - -- \`clix-mcp-server:search_docs\` for personalization behavior and supported - syntax - -If MCP tools are not available, use the bundled references in \`references/\`. - -## Workflow (copy + check off) - -\`\`\` -Personalization progress: -- [ ] 1) Identify where the template runs (message / URL / audience rule) -- [ ] 2) Identify trigger type (event-triggered vs API-triggered) -- [ ] 3) Confirm available inputs (user.*, event.*, trigger.*, device.*) -- [ ] 4) Draft templates with guards/defaults -- [ ] 5) Validate template structure (balance tags, avoid missing vars) -- [ ] 6) Verify in Clix (preview/test payloads, check Message Logs) -\`\`\` - -## 1) Confirm the minimum inputs - -Ask only what’s needed: - -- **Where used**: title/body/subtitle vs URL/deep link vs audience targeting - rule -- **Campaign trigger**: - - Event-triggered → \`event.*\` is available - - API-triggered → \`trigger.*\` is available -- **Inputs**: - - Which \`user.*\` properties are set by the app - - Which \`event.*\` / \`trigger.*\` properties are passed (include example - payload) -- **Fallback policy**: what to show when data is missing (default strings, - guards) - -## 2) Produce a “Template Plan” (before tweaking lots of templates) - -Return a compact table the user can approve: - -- **field** (title/body/url/audience) -- **template** (Liquid) -- **required variables** (and their source: user/event/trigger/device) -- **fallbacks** (default filter and/or conditional guards) -- **example payload** + **expected rendered output** - -## 3) Authoring guidelines (what to do by default) - -- Prefer **simple variables + \`default\`** over complex branching. -- Add **guards** for optional data and for arrays (\`size > 0\`) before looping. -- Prefer **pre-formatted** properties from your app (e.g., \`"7.4 miles"\`, - \`"38m 40s"\`) instead of formatting inside templates. -- Keep logic minimal; complex branching belongs in the app or segmentation - setup. - -## 4) Validation (fast feedback loop) - -If you have a template file (recommended for review), run: - -\`\`\`bash -bash /scripts/validate-template.sh path/to/template.liquid -\`\`\` - -This script does lightweight checks (matching \`{% if %}\`/\`{% endif %}\`, -\`{% for %}\`/\`{% endfor %}\`, and brace balancing). It can’t guarantee the -variables exist — you still need a payload + console verification. - -## 5) Verification checklist - -- **Missing data**: does the template still read well with empty strings? -- **Trigger type**: API triggers use \`trigger.*\` (not \`event.*\`). -- **Message Logs**: check rendering errors and fix syntax issues first. -- **Upstream data**: - - If \`user.*\` is missing → fix user property setting (see - \`clix-user-management\`) - - If \`event.*\` is missing → fix event tracking payload (see - \`clix-event-tracking\`) - -## Progressive Disclosure - -- **Level 1**: This \`SKILL.md\` (always loaded) -- **Level 2**: \`references/\` (load when writing/debugging templates) -- **Level 3**: \`scripts/\` (execute directly, do not load into context) - -## References - -- \`references/template-syntax.md\` - Variables, output, conditionals, loops, - filters -- \`references/common-patterns.md\` - Copy/paste patterns for messages + URLs + - guards -- \`references/debugging.md\` - Troubleshooting missing variables and Message Logs -`, - integration: `--- -name: clix-integration -display-name: SDK Integration -short-description: SDK integration guide -description: - Integrates Clix Mobile SDK into iOS, Android, Flutter, and React Native - projects. Provides step-by-step guidance for installation, initialization, and - verification. Use when the user asks to install, setup, integrate Clix or when - the user types \`clix-integration\` / "clix integration". -user-invocable: true ---- - -# Clix SDK Integration Skill - -This skill provides comprehensive guidance for integrating Clix analytics SDK -into mobile applications. Follow the workflow below to ensure proper -installation and configuration. - -## Integration Strategy (MCP First) - -This skill follows an **"MCP First"** strategy to ensure agents always use the -latest verified SDK source code. - -**Step 1: Check for MCP Capability** - -- Check if the Clix MCP Server tools (\`clix-mcp-server:search_sdk\`, - \`clix-mcp-server:search_docs\`) are available in your toolset. - -**Step 2: Primary Path (MCP Available)** - -- **MUST** use \`clix-mcp-server:search_sdk\` to fetch exact initialization code - for the target platform (e.g., - \`clix-mcp-server:search_sdk(query="initialize", platform="android")\`). -- Use these fetched results as the **Single Source of Truth** for - implementation. -- Do **NOT** rely on static examples if MCP returns valid results. - -**Step 3: Fallback Path (MCP Unavailable)** - -- If checks for \`clix-mcp-server\` fail: - 1. **Ask the user**: "The Clix MCP Server is not detected. It provides the - latest official SDK code. Would you like me to install it now?" - 2. **If User says YES**: - - Run: \`bash scripts/install-mcp.sh --client \` - - The script will: - - Verify the package is available - - Configure the MCP server for the specified client - - (If you omit \`--client\` and multiple clients are installed, the script - will stop and ask you to choose.) - - Automatically configure the MCP server in the appropriate config file - - Provide clear instructions for restart - - Instruct user to restart their agent/IDE to load the new server. - - Stop here (let user restart). - 3. **If User says NO**: - - Fall back to using the static patterns in - \`references/framework-patterns.md\` and \`examples/\`. - - Inform the user: "Proceeding with static fallback examples (may be - outdated)." - -## Interaction Guidelines for Agents - -When using this skill (for example inside OpenCode, Amp, Claude Code, Codex, -Cursor, or other AI IDEs), follow these behaviors: - -- **Start with reconnaissance** - - Inspect the project structure first (list key files like \`package.json\`, - \`Podfile\`, \`build.gradle\`, \`pubspec.yaml\`, \`AppDelegate.swift\`, - \`MainActivity.kt\`, etc.) - - Clearly state what you detected (e.g., “This looks like a React Native - project with iOS and Android”) -- **Ask clarifying questions when needed** - - If multiple platforms are present, ask the user which one(s) to integrate - first - - If the structure is unusual, ask before making assumptions -- **Explain each step briefly** - - Before making changes, say what you are about to do and why (install - dependency, update entrypoint, add config, etc.) - - Keep explanations short but concrete, so the user understands the impact -- **Handle errors gracefully** - - If a command or change fails, show the error, explain likely causes, and - suggest concrete next steps - - Avoid getting stuck in loops—propose a fallback if automation fails -- **End with a verification summary** - - Summarize what was installed/modified (files touched, dependencies added) - - Point out any remaining manual steps (e.g., Xcode capabilities, Firebase - config, running the app to test) - -## Integration Workflow - -### Phase 1: Prerequisite — Credentials (User Setup) - -Before continuing, the user must ensure the following environment variables are -available to the app at runtime: - -- \`CLIX_PROJECT_ID\` -- \`CLIX_PUBLIC_API_KEY\` - -**How to get credentials:** - -- Ask the user to visit \`https://console.clix.so/\` and copy the values for their - Clix project. - -**How to set credentials:** - -- Add them to a local \`.env\` file (recommended), or to your framework’s env - configuration (see \`references/framework-patterns.md\`). -- Ensure \`.env\` is in \`.gitignore\`. - -**Security warning:** - -- Do **not** paste credentials into chat. Enter them directly into the user’s - terminal/editor. - -### Phase 2: Project Type Detection - -**Step 2.1: Identify Platform** - -Examine the codebase structure to determine platform: - -- **iOS**: Look for \`.xcodeproj\`, \`Podfile\`, \`Info.plist\`, Swift/Objective-C - files -- **Android**: Look for \`build.gradle\`, \`AndroidManifest.xml\`, Kotlin/Java files -- **Flutter**: Look for \`pubspec.yaml\`, \`lib/main.dart\`, plus \`ios/\` and - \`android/\` folders -- **React Native**: Look for \`package.json\` with React Native dependencies, - \`android/\` and \`ios/\` folders -- **Other**: If it’s not one of the above, stop and ask the user—this skill is - mobile-only. - -**Priority rule (important):** If React Native or Flutter is detected, treat it -as the primary platform even if native \`ios/\` and \`android/\` folders exist. - -**Step 2.2: Verify Detection** - -Confirm platform detection with user if ambiguous: - -- Multiple platforms detected (e.g., React Native has both iOS and Android) -- Unusual project structure -- Hybrid applications - -### Phase 3: SDK Installation - -**Step 3.1: Install SDK Package** - -Install the appropriate SDK based on detected platform: - -**iOS (Swift Package Manager):** - -\`\`\`swift -// Add to Package.swift or Xcode: https://github.com/clix-so/clix-ios-sdk -\`\`\` - -**iOS (CocoaPods):** - -\`\`\`ruby -# Add to Podfile -pod 'Clix', :git => 'https://github.com/clix-so/clix-ios-sdk.git' -\`\`\` - -**Android (Gradle):** - -\`\`\`kotlin -// Add to build.gradle.kts -implementation("so.clix:clix-android-sdk:latest") -\`\`\` - -**React Native:** - -\`\`\`bash -npm install @clix-so/react-native-sdk -# or -yarn add @clix-so/react-native-sdk -\`\`\` - -**Flutter:** - -- Add the Clix Flutter SDK dependency in \`pubspec.yaml\`: - \`\`\`yaml - dependencies: - clix_flutter: ^0.0.1 - firebase_core: ^3.6.0 - firebase_messaging: ^15.1.3 - \`\`\` -- If MCP tools (\`search_docs\`, \`search_sdk\`) are available, use them to confirm - the latest package version and setup steps. - -**Step 3.2: Verify Installation** - -- Check that package appears in dependency files (\`package.json\`, - \`Podfile.lock\`, \`build.gradle\`) -- Verify import/require statements work -- Check for installation errors - -### Phase 4: SDK Initialization - -**Step 4.1: Locate Entry Point** - -Find the appropriate initialization location: - -- **iOS**: \`AppDelegate.swift\` or \`@main\` app file -- **Android**: \`Application.kt\` or \`Application.java\` class -- **Flutter**: \`lib/main.dart\` -- **React Native**: Root component or \`index.js\` - -**Step 4.2: Initialize SDK** - -Add initialization code following platform-specific patterns (see examples/ -directory): - -**Key Requirements:** - -- Initialize early in application lifecycle -- Use environment variables for credentials (never hardcode) -- Handle initialization errors gracefully -- Follow framework-specific best practices - -**Step 4.3: Configure SDK** - -Set up SDK configuration: - -- Use \`CLIX_PROJECT_ID\` from environment variables -- Use \`CLIX_PUBLIC_API_KEY\` from environment variables -- Set appropriate environment (production/staging/development) -- Configure logging level if needed -- Enable/disable features as required - -### Phase 5: Integration Verification - -**Step 5.1: Code Review** - -Verify integration completeness: - -- SDK is imported/required correctly -- Initialization code is in correct location -- Credentials are loaded from environment variables -- Error handling is implemented -- No hardcoded credentials - -**Step 5.2: Verify Integration** - -Run validation checks: - -- Execute \`scripts/validate-sdk.sh\` if available -- Check that SDK initializes without errors -- Verify environment variables are accessible -- Verify SDK is properly imported and initialized - -### Platform Verification Checklists (UI Steps → Repo Verification) - -Use these checklists to verify manual UI steps were actually completed. - -#### iOS Verification - -- **Dependencies present**: \`Podfile\`/\`Podfile.lock\` or SwiftPM/Xcode references -- **Entitlements present**: an \`*.entitlements\` file exists and is wired to the - correct target(s) -- **Capabilities configured**: \`project.pbxproj\` reflects required capabilities - (as applicable to Push Notifications / Background Modes) - -#### Android Verification - -- **Dependencies present**: module-level \`build.gradle\` / \`build.gradle.kts\` - includes required SDK dependencies -- **Manifest updated**: \`AndroidManifest.xml\` contains required permissions, - services/receivers (as required by the SDK) -- **Firebase config placed** (if used): \`google-services.json\` exists in the - expected module directory (commonly \`app/\`) - -#### React Native Verification - -- **JS dependency**: \`package.json\` includes the Clix package -- **iOS native**: \`ios/Podfile.lock\` updated after \`pod install\` (when required) -- **Android native**: Gradle + Manifest updates present if required by the SDK -- **Initialization**: root entrypoint initializes the SDK exactly once - -#### Flutter Verification - -- **Pub dependency**: \`pubspec.yaml\` includes the required packages and - \`pubspec.lock\` is updated after \`flutter pub get\` -- **iOS native**: \`ios/Podfile.lock\` updated after \`pod install\` (when required) -- **Android native**: Gradle + Manifest updates present if required -- **Initialization**: SDK initialized before \`runApp\` - -**Step 5.3: Documentation** - -Create or update documentation: - -- Add integration notes to README.md -- Document environment variables needed -- Note any framework-specific considerations -- Update \`.env.example\` if it exists - -## Framework-Specific Patterns - -### iOS (Swift) - -**Initialization Pattern:** - -\`\`\`swift -import Clix - -@main -struct MyApp: App { - init() { - // Load credentials from your app configuration (do NOT hardcode). - // Example: store values in Info.plist keys. - let projectId = Bundle.main.object(forInfoDictionaryKey: "CLIX_PROJECT_ID") as? String ?? "" - let apiKey = Bundle.main.object(forInfoDictionaryKey: "CLIX_PUBLIC_API_KEY") as? String - - let config = ClixConfig(projectId: projectId, apiKey: apiKey) - Clix.initialize(config: config) - } -} -\`\`\` - -**Key Points:** - -- Initialize in \`@main\` app struct or \`AppDelegate\` -- Use environment variables, never hardcode -- Handle optional API key for analytics-only mode - -### Android (Kotlin) - -**Initialization Pattern:** - -\`\`\`kotlin -import so.clix.core.Clix -import so.clix.core.ClixConfig - -class MyApplication : Application() { - override fun onCreate() { - super.onCreate() - - // Load credentials from BuildConfig (do NOT hardcode). - val config = ClixConfig.Builder() - .projectId(BuildConfig.CLIX_PROJECT_ID) - .apiKey(BuildConfig.CLIX_PUBLIC_API_KEY) - .build() - - Clix.initialize(this, config) - } -} -\`\`\` - -**Key Points:** - -- Initialize in \`Application.onCreate()\` -- Use \`Application\` context, not Activity context -- Register Application class in \`AndroidManifest.xml\` - -### React Native - -**Initialization Pattern:** - -\`\`\`typescript -import { Clix } from "@clix-so/react-native-sdk"; -import Config from "react-native-config"; - -// In App.tsx or index.js -Clix.initialize({ - projectId: Config.CLIX_PROJECT_ID || "", - apiKey: Config.CLIX_PUBLIC_API_KEY, -}); -\`\`\` - -**Key Points:** - -- Initialize in root component -- Use \`react-native-config\` (or similar) for environment variables -- Link native dependencies if needed - -### Flutter - -**Initialization Pattern:** - -\`\`\`dart -import 'package:clix_flutter/clix_flutter.dart'; - -Future main() async { - // Load credentials from build-time configuration (do NOT hardcode). - // Example: - // flutter run --dart-define=CLIX_PROJECT_ID=... --dart-define=CLIX_PUBLIC_API_KEY=... - const projectId = String.fromEnvironment('CLIX_PROJECT_ID'); - const apiKey = String.fromEnvironment('CLIX_PUBLIC_API_KEY'); - - await Clix.initialize( - ClixConfig( - projectId: projectId, - apiKey: apiKey, - ), - ); - - runApp(const MyApp()); -} -\`\`\` - -**Key Points:** - -- Initialize before \`runApp\` -- Use \`--dart-define\` (or your chosen secret mechanism), never hardcode - -## Error Handling - -**Common Issues:** - -1. **Missing Credentials** - - Error: SDK fails to initialize - - Solution: Verify \`.env\` file exists and contains required variables - - Check: Environment variable names match exactly - -2. **Wrong Initialization Location** - - Error: SDK not tracking events - - Solution: Ensure initialization happens before any SDK usage - - Check: Initialization is in app entry point, not a component - -3. **Environment Variables Not Loading** - - Error: \`undefined\` or empty values - - Solution: Verify env loading mechanism (dotenv, framework config, etc.) - - Check: Variable names match framework requirements (prefixes) - -## Best Practices - -1. **Never hardcode credentials** - Always use environment variables -2. **Initialize early** - SDK should initialize before app starts or as early as - possible -3. **Handle errors gracefully** - Wrap initialization in try-catch blocks -4. **Use appropriate environment** - Set production/staging/development based on - build -5. **Verify integration** - Use validation scripts to verify SDK is properly - integrated -6. **Document changes** - Update README and configuration files -7. **Version control** - Add \`.env\` to \`.gitignore\`, commit \`.env.example\` - -## Docs Usage Note (MCP vs Static vs Web Docs) - -- If MCP tools are available: treat \`search_sdk\` results as the source of truth - for initialization/API usage. -- If MCP tools are unavailable: you may reference the official quickstarts below - for **manual steps**, and use \`examples/\` + \`references/\` for code patterns. - -**Official quickstarts:** - -- iOS: \`https://docs.clix.so/sdk-quickstart-ios\` -- Android: \`https://docs.clix.so/sdk-quickstart-android\` -- React Native: \`https://docs.clix.so/sdk-quickstart-react-native\` -- Flutter: \`https://docs.clix.so/sdk-quickstart-flutter\` - -## Progressive Disclosure - -- **Level 1**: This SKILL.md file (always loaded) -- **Level 2**: \`references/\` directory (loaded when needed for specific topics) -- **Level 3**: \`examples/\` directory (loaded when framework-specific examples - needed) -- **Level 4**: \`scripts/\` directory (executed directly, not loaded into context) - -## References - -For detailed information, see: - -- \`references/sdk-reference.md\` - Complete SDK API documentation -- \`references/framework-patterns.md\` - Framework-specific integration patterns -- \`references/error-handling.md\` - Common errors and troubleshooting -- \`references/mcp-integration.md\` - Optional: MCP server usage guide (for - tool-augmented mode) - -## Examples - -Working code examples available in \`examples/\` directory: - -- \`ios-integration.swift\` - iOS integration (UIKit/SwiftUI) -- \`android-integration.kt\` - Android integration (Application) -- \`flutter-integration.dart\` - Flutter integration (main.dart) -- \`react-native-integration.tsx\` - React Native integration (App.tsx) - -## Scripts - -Utility scripts available in \`scripts/\` directory: - -- \`validate-sdk.sh\` - Validate SDK installation and initialization (bash, - deterministic) - -Run scripts with \`--help\` first to see usage. Do not read source code unless -customization is absolutely necessary. -`, - 'api-triggered-campaigns': `--- -name: clix-api-triggered-campaigns -display-name: API-Triggered Campaigns -short-description: API-triggered campaign setup -description: - Helps developers configure API-triggered campaigns in the Clix console and - trigger them from backend services with safe auth, payload schemas, dynamic - audience filters (trigger.*), and personalization best practices. Use when the - user mentions transactional notifications, backend-triggered sends, - campaign_id trigger APIs, or "API-triggered campaigns". -user-invocable: true ---- - -# API-Triggered Campaigns (Backend → Clix) - -Use this skill to set up **API-triggered campaigns** in the Clix console and -trigger them from your backend with dynamic data (\`trigger.*\`) for: - -- Transactional notifications (orders, password reset, receipts) -- Workflow messages (assignments, approvals) -- System alerts (moderation, support tickets) -- Programmatic sends where marketers/ops should control content + targeting - -## What the official docs guarantee (high-signal) - -- API-triggered campaigns are configured in the console, then triggered via: - \`POST /api/v1/campaigns/{campaign_id}:trigger\` -- Authentication uses **secret** headers: - - \`X-Clix-Project-ID\` - - \`X-Clix-API-Key\` -- Request \`properties\` become **\`trigger.*\`** for: - - **Dynamic audience filtering** (console audience rules) - - **Personalization** in templates (title/body/deep links) -- \`audience.broadcast=true\` sends to all users eligible under the campaign’s - segment definition; \`audience.targets\` narrows to specific users/devices - (still filtered by the segment definition). -- Dynamic audience filters are intentionally constrained for performance: **max - 3 attributes** in the audience definition. - -## MCP-first (source of truth) - -If Clix MCP tools are available, treat them as the **source of truth**: - -- Use \`clix-mcp-server:search_docs\` to confirm the latest API contract + limits: - - query examples: - - \`"API-triggered campaign trigger endpoint"\` - - \`"campaigns/{campaign_id}:trigger audience targets broadcast"\` - - \`"trigger.* dynamic audience filters limitations"\` - -If MCP tools are not available, use the bundled references: - -- API contract → \`references/api-contract.md\` -- Console setup + dynamic filters → \`references/console-setup.md\` -- Backend patterns (auth, retries, timeouts) → \`references/backend-patterns.md\` -- Security/key handling → \`references/security-and-keys.md\` -- Personalization + dynamic filters → - \`references/personalization-and-dynamic-filters.md\` -- Debugging checklist → \`references/debugging.md\` - -## Workflow (copy + check off) - -\`\`\` -API-triggered campaign progress: -- [ ] 1) Confirm goals + trigger source (what backend event sends the message?) -- [ ] 2) Define campaign contract (properties keys/types; PII policy) -- [ ] 3) Configure campaign in console (API-triggered + audience rules + templates) -- [ ] 4) Implement backend trigger wrapper (auth, timeout, retries, logging) -- [ ] 5) Validate trigger plan JSON (schema + naming + safety) -- [ ] 6) Verify in Clix (test payloads, Message Logs, segment matching) -\`\`\` - -## 1) Confirm the minimum inputs - -Ask only what’s needed: - -- **Campaign**: where is it in the console? do you already have \`campaign_id\`? -- **Channel**: push / in-app / email / etc. (affects message template fields) -- **Audience mode**: broadcast vs explicit targets -- **Dynamic filter keys**: which \`trigger.*\` keys are used in audience rules -- **Properties**: list of keys + types + example values (avoid PII by default) -- **Backend**: runtime and HTTP client (Node/Fetch, Axios, Python, Go, etc.) - -## 2) Create a “Trigger Plan” (before touching backend code) - -Create \`api-trigger-plan.json\` in \`.clix/\` (recommended) or project root. - -**Recommended location**: \`.clix/api-trigger-plan.json\` - -**Plan schema (high-level):** - -- \`campaign_id\` (string) -- \`audience.mode\`: \`"broadcast" | "targets" | "default"\` -- \`audience.targets\` (if mode is \`"targets"\`) -- \`dynamic_filter_keys\` (array of up to 3 snake_case keys) -- \`properties\` (map of snake_case keys → \`{ type, required?, example?, pii? }\`) - -Example: - -\`\`\`json -{ - "campaign_id": "019aa002-1d0e-7407-a0c5-5bfa8dd2be30", - "audience": { - "mode": "broadcast" - }, - "dynamic_filter_keys": ["store_location"], - "properties": { - "store_location": { - "type": "string", - "required": true, - "example": "San Francisco" - }, - "order_id": { "type": "string", "required": true, "example": "ORD-12345" }, - "item_count": { "type": "number", "required": true, "example": 3 }, - "pickup_time": { "type": "string", "required": false, "example": "2:30 PM" } - } -} -\`\`\` - -## 3) Configure campaign in the console (API-triggered) - -- Set the campaign type to **API-Triggered**. -- Build audience rules using \`{{ trigger.* }}\` for dynamic filters. -- Use \`{{ trigger.* }}\` in message templates + deep links. - -See: \`references/console-setup.md\` for exact guidance and limitations. - -## 4) Implement backend trigger wrapper (best practices) - -Backend wrapper responsibilities: - -- **Auth**: load \`X-Clix-Project-ID\` and \`X-Clix-API-Key\` from - environment/secret store (never commit). -- **Timeout**: set a short timeout (e.g., 3–10s) and fail fast. -- **Retries**: retry only on transient failures (network/5xx), with backoff; do - not retry blindly on 4xx. -- **Dedupe**: prevent double-sends in your system (e.g., unique key per order - event) since the API call is “send-like”. -- **Logging**: log \`campaign_id\`, your correlation id (order id), and the Clix - response (e.g., \`trigger_id\`). - -Copy/paste examples: - -- Node: \`examples/trigger-campaign-node.js\` -- Python: \`examples/trigger-campaign-python.py\` - -## 5) Validate the plan (fast feedback loop) - -Run: - -\`\`\`bash -bash /scripts/validate-api-trigger-plan.sh .clix/api-trigger-plan.json -\`\`\` - -This validator checks: - -- valid JSON -- \`campaign_id\` present -- \`dynamic_filter_keys\` is ≤ 3 and snake_case -- \`properties\` keys are snake_case and have valid types -- example values match declared types -- \`targets\` entries specify exactly one of \`project_user_id\` or \`device_id\` - -## 6) Verify (Clix + end-to-end) - -Minimum verification: - -- Campaign is **API-Triggered** and \`campaign_id\` matches. -- If using dynamic audience filters, the \`trigger.*\` keys exist in the API call - and match audience rules exactly. -- Trigger once with a known-good payload; confirm delivery + inspect Message - Logs for rendering errors. - -See \`references/debugging.md\`. - -## Progressive Disclosure - -- **Level 1**: This \`SKILL.md\` (always loaded) -- **Level 2**: \`references/\` (load when implementing details) -- **Level 3**: \`examples/\` (load when copy/pasting backend code) -- **Level 4**: \`scripts/\` (execute directly; do not load into context) - -## References - -- \`references/api-contract.md\` -- \`references/console-setup.md\` -- \`references/backend-patterns.md\` -- \`references/security-and-keys.md\` -- \`references/personalization-and-dynamic-filters.md\` -- \`references/debugging.md\` -`, - 'event-tracking': `--- -name: clix-event-tracking -display-name: Event Tracking -short-description: Event tracking setup -description: - Implements Clix event tracking (Clix.trackEvent) with consistent naming, safe - property schemas, and campaign-ready validation. Use when adding, reviewing, - or debugging event tracking; when configuring event-triggered campaigns; or - when the user mentions events, tracking, funnels, or properties — or when the - user types \`clix-event-tracking\`. -user-invocable: true ---- - -# Tracking Clix Events - -Use this skill to help developers design and implement **Clix event tracking** -via \`Clix.trackEvent(...)\` so events can drive **event-triggered campaigns**, -**audience filters**, and **personalization**. - -## What the official docs guarantee (high-signal) - -- **When to track**: identify the user action/system event, then attach - meaningful properties (funnel checkpoints, milestones, button taps). -- **Property normalization**: booleans/numbers/strings map directly; date-like - values serialize to ISO 8601; unsupported objects become strings. -- **Error handling**: event tracking calls can throw—always handle errors so the - app stays robust. - -## MCP-first (source of truth) - -If Clix MCP tools are available, treat them as the **source of truth**: - -- \`clix-mcp-server:search_docs\` for conceptual behavior (campaign triggers, - personalization) -- \`clix-mcp-server:search_sdk\` for exact SDK signatures per platform - -If MCP tools are not available, use the bundled references: - -- Event API contract + pitfalls → \`references/trackevent-contract.md\` -- Naming + schemas + privacy → \`references/naming-and-schema.md\` -- Implementation patterns → \`references/implementation-patterns.md\` -- Campaign mapping → \`references/campaign-mapping.md\` -- Debugging checklist → \`references/debugging.md\` - -## Workflow (copy + check off) - -\`\`\` -Event tracking progress: -- [ ] 1) Confirm platform(s) and goals (analytics vs campaign triggers) -- [ ] 2) Propose event plan (names, when fired, properties, where in code) -- [ ] 3) Validate plan (names, keys, types, PII constraints) -- [ ] 4) Implement trackEvent calls (platform-correct) -- [ ] 5) Verify: events fire once, serialize cleanly, match campaign configs -\`\`\` - -## 1) Confirm the minimum inputs - -Ask only what’s needed: - -- **Platform**: iOS / Android / React Native / Flutter -- **Goal**: analytics only, event-triggered campaigns, or both -- **Top flows** (1–3): e.g., onboarding, checkout, subscription -- **PII policy**: what must never be sent (email/phone/name/free-text, etc.) - -## 2) Propose an “Event Plan” (before touching code) - -Return a compact table the user can approve: - -- **event_name** (stable, \`snake_case\`) -- **when** (exact moment the event fires) -- **properties** (key + type, mark required vs optional) -- **location** (file/function/UI handler/network response) -- **purpose** (campaign trigger / analytics / both) - -If campaigns are involved, remind: **event names and property keys must match -exactly** in the Clix console. - -## 3) Validate the plan (fast feedback loop) - -Create \`event-plan.json\` in \`.clix/\` directory (recommended) or project root: - -**Recommended location**: \`.clix/event-plan.json\` - -- Organized: keeps tooling configs together -- Hidden: doesn't clutter project root -- Committable: planning document for team review - -**Alternative**: \`event-plan.json\` in project root (simpler, but less organized) - -**For agents**: Locate \`scripts/validate-event-plan.sh\` in the installed skill -directory, then run it: - -\`\`\`bash -# From project root: -bash /scripts/validate-event-plan.sh .clix/event-plan.json -# Or if in root: -bash /scripts/validate-event-plan.sh event-plan.json -\`\`\` - -The skill directory is typically: - -- \`.cursor/skills/event-tracking/\` (Cursor) -- \`.claude/skills/event-tracking/\` (Claude Code) -- \`.vscode/skills/event-tracking/\` (VS Code/Amp) -- Or check where this skill was installed - -If validation fails: fix the plan first, then implement. - -## 4) Implement tracking (platform-correct) - -Use MCP to fetch the exact \`trackEvent\` signature for the platform; then: - -- **Place calls at stable boundaries** (action confirmed, request succeeded, - state updated) -- **Avoid duplicates** (don’t fire on every render; debounce where needed) -- **Avoid null/complex values** (prefer primitives; serialize dates to ISO) -- **Do not track PII by default** - -See \`references/implementation-patterns.md\` for placement heuristics and code -patterns. - -## 5) Verify - -Minimum verification: - -- Event fires **exactly once** per user action (or the intended cadence) -- Properties are **primitive + stable** (no null surprises) -- For campaigns: console trigger conditions match **exact names/keys** - -For troubleshooting steps, see \`references/debugging.md\`. -`, - 'user-management': `--- -name: clix-user-management -display-name: User Management -short-description: User management setup -description: - Implements Clix user identification and user properties (setUserId, - removeUserId, setUserProperty/setUserProperties, - removeUserProperty/removeUserProperties) with safe schemas, logout best - practices, and campaign-ready personalization/audience usage. Use when the - user mentions login/logout, userId, user properties, personalization, audience - targeting or when the user types \`clix-user-management\`. -user-invocable: true ---- - -# Clix User Management - -Use this skill to help developers implement **Clix user identification** and -**user properties** so campaigns can use \`user.*\` variables and audience -filters, and so user identity is consistent across devices and sessions. - -## What the official docs guarantee (high-signal) - -- **Anonymous vs identified**: if no user ID is set, Clix treats the user as - anonymous; setting a user ID converts the anonymous user into an identified - user and links prior activity. -- **Logout**: **do not** call \`setUserId(null)\` on logout; handle logout in app - logic only; when a different user logs in, call \`setUserId(newUserId)\` to - switch. -- **User properties**: values are strings, numbers, or booleans; user operations - can throw—handle errors. - -## MCP-first (source of truth) - -If Clix MCP tools are available, treat them as the **source of truth**: - -- \`clix-mcp-server:search_docs\` for conceptual behavior and logout guidance -- \`clix-mcp-server:search_sdk\` for exact SDK signatures per platform - -If MCP tools are not available, use the bundled references: - -- Contract + pitfalls → \`references/user-management-contract.md\` -- Logout + switching rules → \`references/logout-and-switching.md\` -- Property schema + PII → \`references/property-schema.md\` -- Implementation patterns → \`references/implementation-patterns.md\` -- Personalization + audience mapping → - \`references/personalization-and-audience.md\` -- Debugging checklist → \`references/debugging.md\` - -## Workflow (copy + check off) - -\`\`\` -User management progress: -- [ ] 1) Confirm platform(s) and auth model (anonymous browsing? login? shared devices?) -- [ ] 2) Propose user plan (when setUserId/removeUserId, properties, logout policy) -- [ ] 3) Validate plan (PII, property types, logout rules) -- [ ] 4) Implement (platform-correct calls + error handling) -- [ ] 5) Verify (switching works, properties appear, campaigns can target/personalize) -\`\`\` - -## 1) Confirm the minimum inputs - -Ask only what’s needed: - -- **Platform**: iOS / Android / React Native / Flutter -- **Auth events**: where login success and logout happen in code -- **User identifier**: what stable ID to use (prefer internal user id, not - email) -- **PII policy**: what must never be stored as user properties -- **Campaign goals**: personalization, audience filters, or both - -## 2) Propose a “User Plan” (before touching code) - -Return a compact table: - -- **user_id source**: where it comes from (auth response, local db) -- **setUserId timing**: exact point (after login success / token saved) -- **logout behavior**: explicitly “no call to setUserId(null)” -- **properties**: key + type, required vs optional -- **purpose**: personalization / audience / analytics - -## 3) Validate the plan (fast feedback loop) - -Create \`user-plan.json\` in \`.clix/\` (recommended) or project root. - -**For agents**: locate \`scripts/validate-user-plan.sh\` in the installed skill -directory and run: - -\`\`\`bash -# From project root: -bash /scripts/validate-user-plan.sh .clix/user-plan.json -# Or if in root: -bash /scripts/validate-user-plan.sh user-plan.json -\`\`\` - -If validation fails: fix the plan first, then implement. - -## 4) Implement (platform-correct) - -Use MCP to fetch the exact signatures per platform, then: - -- Place \`setUserId(...)\` **after** login/signup is confirmed. -- On logout: **do nothing with Clix** (no \`setUserId(null)\`). -- When switching users: call \`setUserId(newUserId)\` after the new login - succeeds. -- Set user properties only from controlled sources; avoid free-text/PII. -- Always handle errors (async calls can throw). - -## 5) Verify - -- Identity: - - Anonymous flow works without calling \`setUserId\` - - After login, \`setUserId\` is called once and stable - - Switching accounts updates the active profile -- Properties: - - Properties are primitives (string/number/boolean) and consistent - - Campaign audiences can filter on them - - Messages can use \`user.*\` personalization -`, 'local-install': `# Clix SDK Autonomous Install You are an autonomous AI agent that handles \`/install\` runtime work for Clix SDK projects. @@ -1341,99 +278,30 @@ Use \`/install\` to run interactive setup for missing Firebase credentials. }; /** - * Embedded skill metadata. + * Package-based metadata is intentionally empty. */ -export const EMBEDDED_SKILL_METADATA: SkillMetadata[] = [ - { - folder: 'personalization', - name: 'clix-personalization', - commandName: 'personalization', - displayName: 'Personalization', - shortDescription: 'Personalization templates', - description: - 'Helps developers author and debug Clix personalization templates (Liquid-style) for message content, deep links/URLs, and audience targeting. Use when the user mentions personalization variables, Liquid, templates, conditional logic, loops, filters, deep links, message logs, or when the user types `clix-personalization`.', - userInvocable: true, - }, - { - folder: 'integration', - name: 'clix-integration', - commandName: 'integration', - displayName: 'SDK Integration', - shortDescription: 'SDK integration guide', - description: - 'Integrates Clix Mobile SDK into iOS, Android, Flutter, and React Native projects. Provides step-by-step guidance for installation, initialization, and verification. Use when the user asks to install, setup, integrate Clix or when the user types `clix-integration` / "clix integration".', - userInvocable: true, - }, - { - folder: 'api-triggered-campaigns', - name: 'clix-api-triggered-campaigns', - commandName: 'api-triggered-campaigns', - displayName: 'API-Triggered Campaigns', - shortDescription: 'API-triggered campaign setup', - description: - 'Helps developers configure API-triggered campaigns in the Clix console and trigger them from backend services with safe auth, payload schemas, dynamic audience filters (trigger.*), and personalization best practices. Use when the user mentions transactional notifications, backend-triggered sends, campaign_id trigger APIs, or "API-triggered campaigns".', - userInvocable: true, - }, - { - folder: 'event-tracking', - name: 'clix-event-tracking', - commandName: 'event-tracking', - displayName: 'Event Tracking', - shortDescription: 'Event tracking setup', - description: - 'Implements Clix event tracking (Clix.trackEvent) with consistent naming, safe property schemas, and campaign-ready validation. Use when adding, reviewing, or debugging event tracking; when configuring event-triggered campaigns; or when the user mentions events, tracking, funnels, or properties — or when the user types `clix-event-tracking`.', - userInvocable: true, - }, - { - folder: 'user-management', - name: 'clix-user-management', - commandName: 'user-management', - displayName: 'User Management', - shortDescription: 'User management setup', - description: - 'Implements Clix user identification and user properties (setUserId, removeUserId, setUserProperty/setUserProperties, removeUserProperty/removeUserProperties) with safe schemas, logout best practices, and campaign-ready personalization/audience usage. Use when the user mentions login/logout, userId, user properties, personalization, audience targeting or when the user types `clix-user-management`.', - userInvocable: true, - }, -]; +export const EMBEDDED_SKILL_METADATA: SkillMetadata[] = []; -/** - * Check if embedded skills are available. - */ export function hasEmbeddedSkills(): boolean { return Object.keys(EMBEDDED_SKILLS).length > 0; } -/** - * Get an embedded skill by folder name. - */ export function getEmbeddedSkill(skillFolder: string): string | undefined { return EMBEDDED_SKILLS[skillFolder]; } -/** - * Get all embedded skill folder names. - */ export function getEmbeddedSkillFolders(): string[] { return Object.keys(EMBEDDED_SKILLS); } -/** - * Get metadata for all embedded skills. - */ export function getEmbeddedSkillMetadata(): SkillMetadata[] { return EMBEDDED_SKILL_METADATA; } -/** - * Get metadata for a specific skill by command name. - */ -export function getSkillMetadataByCommand(commandName: string): SkillMetadata | undefined { - return EMBEDDED_SKILL_METADATA.find((m) => m.commandName === commandName); +export function getSkillMetadataByCommand(_commandName: string): SkillMetadata | undefined { + return undefined; } -/** - * Get metadata for a specific skill by folder name. - */ -export function getSkillMetadataByFolder(folder: string): SkillMetadata | undefined { - return EMBEDDED_SKILL_METADATA.find((m) => m.folder === folder); +export function getSkillMetadataByFolder(_folder: string): SkillMetadata | undefined { + return undefined; } diff --git a/src/lib/executor.ts b/src/lib/executor.ts index eba4e14..2776325 100644 --- a/src/lib/executor.ts +++ b/src/lib/executor.ts @@ -4,10 +4,6 @@ export interface ExecuteOptions { workingDirectory?: string; allowedTools?: string[]; signal?: AbortSignal; - /** One-shot mode: disable session persistence and resumption */ - oneShot?: boolean; - /** Interactive mode: enable user prompts and approval dialogs */ - interactive?: boolean; } export type AgentTextStreamMode = 'append' | 'replace'; @@ -24,39 +20,10 @@ export interface AgentMessage { metadata?: Record; } -export interface ConversationMessage { - role: 'user' | 'assistant'; - content: string; -} - -export interface CompactionResult { - compacted: boolean; - originalLength: number; - newLength: number; - messagesCompacted: number; - messagesPreserved: number; -} - export interface AgentExecutor { name: string; execute(prompt: string, options?: ExecuteOptions): AsyncGenerator; isAvailable(): Promise; - - clearHistory(): void; - getHistory(): ConversationMessage[]; - setHistory(history: ConversationMessage[]): void; - - /** Get underlying CLI/session identifier if supported. */ - getSessionId(): string | null; - /** Set underlying CLI/session identifier if supported. */ - setSessionId(sessionId: string | null): void; - - /** Check if history needs compaction */ - needsCompaction(): boolean; - /** Compact history by summarizing older messages */ - compactHistory(force?: boolean): Promise; - /** Reset session without clearing history (for session conflict recovery) */ - resetSession(): void; } export async function createExecutor(agent: AgentInfo): Promise { diff --git a/src/lib/executors/__tests__/claude-executor.test.ts b/src/lib/executors/__tests__/claude-executor.test.ts index 323ca41..7f8a894 100644 --- a/src/lib/executors/__tests__/claude-executor.test.ts +++ b/src/lib/executors/__tests__/claude-executor.test.ts @@ -40,59 +40,19 @@ describe('ClaudeExecutor', () => { describe('buildArgs', () => { test('should enable bypass permission mode and dangerous skip permissions', () => { // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test prompt', { oneShot: true }); + const args = (executor as any).buildArgs('test prompt'); expect(args).toContain('--allow-dangerously-skip-permissions'); expect(args).toContain('--permission-mode'); expect(args).toContain('bypassPermissions'); }); - test('should include no-session-persistence in one-shot mode', () => { + test('should always include no-session-persistence', () => { // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test prompt', { oneShot: true }); + const args = (executor as any).buildArgs('test prompt'); expect(args).toContain('--no-session-persistence'); }); - - test('should include resume flag in chat mode when session exists', () => { - executor.setSessionId('session-123'); - // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test prompt', { oneShot: false }); - - expect(args).toContain('--resume'); - expect(args).toContain('session-123'); - expect(args).not.toContain('--no-session-persistence'); - }); - }); -}); - -describe('ClaudeExecutor session management', () => { - describe('session ID preservation on abort', () => { - test('should preserve session ID when execution is aborted', async () => { - const executor = new ClaudeExecutor(); - const testSessionId = 'test-session-123'; - - // Set up executor with a session ID (simulating a previous execution) - executor.setSessionId(testSessionId); - - // Verify session ID is set - expect(executor.getSessionId()).toBe(testSessionId); - - // Create an already-aborted AbortController - const abortController = new AbortController(); - abortController.abort(); - - // Execute with aborted signal - const messages: unknown[] = []; - for await (const message of executor.execute('test prompt', { - signal: abortController.signal, - })) { - messages.push(message); - } - - // Session ID should be preserved after abort (not reset to null) - expect(executor.getSessionId()).toBe(testSessionId); - }); }); }); diff --git a/src/lib/executors/__tests__/codex-executor.test.ts b/src/lib/executors/__tests__/codex-executor.test.ts index 3611dfe..8a62448 100644 --- a/src/lib/executors/__tests__/codex-executor.test.ts +++ b/src/lib/executors/__tests__/codex-executor.test.ts @@ -40,7 +40,7 @@ describe('CodexExecutor', () => { describe('buildArgs', () => { test('should enable dangerous bypass flag by default', () => { // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test prompt', { oneShot: true }); + const args = (executor as any).buildArgs('test prompt'); expect(args).toContain('--dangerously-bypass-approvals-and-sandbox'); expect(args).not.toContain('--sandbox'); diff --git a/src/lib/executors/__tests__/copilot-executor.test.ts b/src/lib/executors/__tests__/copilot-executor.test.ts index 3891a1a..177fec5 100644 --- a/src/lib/executors/__tests__/copilot-executor.test.ts +++ b/src/lib/executors/__tests__/copilot-executor.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; -import type { ConversationMessage } from '../../executor'; import { CopilotExecutor } from '../copilot-executor'; // Mock CLI process manager @@ -54,7 +53,7 @@ describe('CopilotExecutor', () => { describe('buildArgs', () => { test('should include -p flag and prompt', () => { // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test prompt', { oneShot: true }); + const args = (executor as any).buildArgs('test prompt'); expect(args).toContain('-p'); expect(args).toContain('test prompt'); @@ -62,36 +61,29 @@ describe('CopilotExecutor', () => { test('should include --silent flag', () => { // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test', { oneShot: true }); + const args = (executor as any).buildArgs('test'); expect(args).toContain('--silent'); }); test('should include --allow-all-tools flag', () => { // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test', { oneShot: true }); + const args = (executor as any).buildArgs('test'); expect(args).toContain('--allow-all-tools'); }); - test('should not include --continue in one-shot mode', () => { + test('should not include --continue flag', () => { // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test', { oneShot: true }); + const args = (executor as any).buildArgs('test'); expect(args).not.toContain('--continue'); }); - test('should include --continue in chat mode', () => { - // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test', { oneShot: false }); - - expect(args).toContain('--continue'); - }); - test('should include --log-level debug in debug mode', () => { process.env.DEBUG = '1'; // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - const args = (executor as any).buildArgs('test', { oneShot: true }); + const args = (executor as any).buildArgs('test'); expect(args).toContain('--log-level'); expect(args).toContain('debug'); @@ -99,53 +91,4 @@ describe('CopilotExecutor', () => { process.env.DEBUG = undefined; }); }); - - describe('history management', () => { - test('should start with empty history', () => { - expect(executor.getHistory()).toEqual([]); - }); - - test('should clear history', () => { - executor.setHistory([ - { role: 'user', content: 'test' }, - { role: 'assistant', content: 'response' }, - ]); - - executor.clearHistory(); - expect(executor.getHistory()).toEqual([]); - }); - - test('should set history', () => { - const history: ConversationMessage[] = [ - { role: 'user', content: 'test' }, - { role: 'assistant', content: 'response' }, - ]; - - executor.setHistory(history); - expect(executor.getHistory()).toEqual(history); - }); - }); - - describe('session management', () => { - test('should reset session', () => { - // Set up some session state - // biome-ignore lint/suspicious/noExplicitAny: Testing protected property - (executor as any).sessionId = 'test-session-123'; - - executor.resetSession(); - - // biome-ignore lint/suspicious/noExplicitAny: Testing protected property - expect((executor as any).sessionId).toBeNull(); - }); - - test('should reset session on compaction complete', () => { - // biome-ignore lint/suspicious/noExplicitAny: Testing protected property - (executor as any).sessionId = 'test-session-123'; - // biome-ignore lint/suspicious/noExplicitAny: Testing protected method - (executor as any).onCompactionComplete(); - - // biome-ignore lint/suspicious/noExplicitAny: Testing protected property - expect((executor as any).sessionId).toBeNull(); - }); - }); }); diff --git a/src/lib/executors/base-executor.ts b/src/lib/executors/base-executor.ts index ee04d65..5dbad71 100644 --- a/src/lib/executors/base-executor.ts +++ b/src/lib/executors/base-executor.ts @@ -1,23 +1,12 @@ /** * Base Executor - * Unified abstract base class for all CLI-based agent executors - * Provides common implementation for history management, process spawning, and stream parsing + * Unified abstract base class for all CLI-based agent executors. + * Runs each request as a standalone execution (no conversation/session persistence). */ import { CLIX_SYSTEM_PROMPT } from '../constants/system-prompt'; import { createLogger, type Logger } from '../debug/logger'; -import type { - AgentExecutor, - AgentMessage, - CompactionResult, - ConversationMessage, - ExecuteOptions, -} from '../executor'; -import { - calculateHistorySize, - DEFAULT_COMPACTION_THRESHOLD, - getCompactionService, -} from '../services/history-compaction'; +import type { AgentExecutor, AgentMessage, ExecuteOptions } from '../executor'; import { commandExists, parseJSONLStream, @@ -55,23 +44,15 @@ export interface StreamContext { /** * Abstract base class for CLI-based agent executors. - * Implements common functionality like history management, process lifecycle, and stream parsing. + * Implements common functionality like process lifecycle and stream parsing. * * Subclasses must implement: * - buildArgs(): Build CLI arguments for execution * - getStreamParserType(): Return 'jsonl' or 'text' for stream parser selection * - processStreamData(): Process a single stream item and return AgentMessage(s) - * - * Subclasses may optionally override: - * - extractSessionId(): Extract session ID from stream data (for session resumption) - * - onCompactionComplete(): Hook called after compaction (e.g., to reset session) - * - getInterruptedSuffix(): Customize interrupted content suffix */ export abstract class BaseExecutor implements AgentExecutor { readonly name: string; - protected history: ConversationMessage[] = []; - protected sessionId: string | null = null; - protected systemPromptInjected = false; protected readonly log: Logger; private readonly command: string; @@ -87,7 +68,7 @@ export abstract class BaseExecutor implements AgentExecutor { /** * Build CLI arguments for the given prompt. * @param prompt - User prompt to execute - * @param options - Execute options (includes oneShot flag) + * @param options - Execute options * @returns Array of CLI arguments */ protected abstract buildArgs(prompt: string, options?: ExecuteOptions): string[]; @@ -113,24 +94,6 @@ export abstract class BaseExecutor implements AgentExecutor { context: StreamContext, ): AgentMessage | AgentMessage[] | null; - /** - * Extract session ID from stream data (optional). - * Override this for CLIs that support session resumption. - * - * @param data - Stream data (JSON object for JSONL, string for text) - */ - protected extractSessionId(_data: unknown): string | null { - return null; - } - - /** - * Hook called after compaction completes. - * Override to perform cleanup (e.g., reset session ID). - */ - protected onCompactionComplete(): void { - // Default: no-op - } - /** * Hook called when request is aborted with partial content. * Override to customize the interrupted content suffix. @@ -156,16 +119,8 @@ export abstract class BaseExecutor implements AgentExecutor { return undefined; } - protected getPreparedPrompt(prompt: string, options?: ExecuteOptions): string { - const shouldInject = options?.oneShot || !this.systemPromptInjected; - if (!shouldInject) { - return prompt; - } - - this.systemPromptInjected = true; - return `${CLIX_SYSTEM_PROMPT} - -${prompt}`; + protected getPreparedPrompt(prompt: string): string { + return `${CLIX_SYSTEM_PROMPT}\n\n${prompt}`; } async isAvailable(): Promise { @@ -176,22 +131,12 @@ ${prompt}`; * Handle execution errors and yield appropriate error messages. */ private handleExecutionError(error: unknown): AgentMessage { - // Remove the failed user message from history - this.history.pop(); - - // Handle abort errors gracefully - preserve session ID for user cancellation if (error instanceof DOMException && error.name === 'AbortError') { return { type: 'error', content: 'Request cancelled' }; } - // Reset session only for actual errors (not user cancellation) - this.sessionId = null; - - // Extract error message from various error types - // Use duck typing first to handle cross-realm Error objects let errorMessage = 'Unknown error occurred'; if (error && typeof error === 'object' && 'message' in error) { - // Duck typing: check for message property first (handles cross-realm Errors) const msg = (error as { message: unknown }).message; errorMessage = typeof msg === 'string' && msg ? msg : String(msg); } else if (error instanceof Error) { @@ -208,7 +153,6 @@ ${prompt}`; errorMessage = String(error); } - // Add debug info if still unknown if (errorMessage === 'Unknown error occurred' || !errorMessage) { errorMessage = `Unknown error (type: ${typeof error}, value: ${String(error)})`; } @@ -238,16 +182,12 @@ ${prompt}`; } async *execute(prompt: string, options?: ExecuteOptions): AsyncGenerator { - // Check if CLI is available if (!(await this.isAvailable())) { yield { type: 'error', content: this.notFoundMessage }; return; } - // Add user message to history - this.history.push({ role: 'user', content: prompt }); - - const preparedPrompt = this.getPreparedPrompt(prompt, options); + const preparedPrompt = this.getPreparedPrompt(prompt); const args = this.buildArgs(preparedPrompt, options); try { @@ -280,7 +220,6 @@ ${prompt}`; count: 0, }; - // Start waiting for process exit (don't await yet) const exitPromise = waitForProcessExit(proc, stderr); try { @@ -305,25 +244,11 @@ ${prompt}`; }); } - // Check abort signal if (options?.signal?.aborted) { - if (context.assistantContent) { - this.history.push({ - role: 'assistant', - content: context.assistantContent + this.getInterruptedSuffix(), - }); - } kill(); throw new DOMException('Aborted', 'AbortError'); } - // Extract session ID if available - const sessionId = this.extractSessionId(data); - if (sessionId) { - this.log.debug('Extracted session ID', { sessionId }); - this.sessionId = sessionId; - } - const mapped = this.processStreamData(data, context); if (!mapped) { @@ -331,7 +256,6 @@ ${prompt}`; continue; } - // Handle both single and multiple messages const messages = Array.isArray(mapped) ? mapped : [mapped]; for (const msg of messages) { this.log.debug('Yielding mapped message', { @@ -349,65 +273,15 @@ ${prompt}`; assistantContentLength: context.assistantContent.length, }); - // Wait for process to complete await exitPromise; this.log.debug('Process exited successfully', { pid: proc.pid }); } finally { kill(); } - // Add assistant response to history - if (context.assistantContent) { - this.history.push({ role: 'assistant', content: context.assistantContent }); - } - yield { type: 'complete', content: 'Agent execution completed' }; } catch (error) { yield this.handleExecutionError(error); } } - - clearHistory(): void { - this.sessionId = null; - this.systemPromptInjected = false; - this.history = []; - } - - resetSession(): void { - this.sessionId = null; - this.systemPromptInjected = false; - } - - getSessionId(): string | null { - return this.sessionId; - } - - setSessionId(sessionId: string | null): void { - this.sessionId = sessionId; - } - - getHistory(): ConversationMessage[] { - return [...this.history]; - } - - setHistory(history: ConversationMessage[]): void { - this.history = [...history]; - this.sessionId = null; - } - - needsCompaction(): boolean { - return calculateHistorySize(this.history) >= DEFAULT_COMPACTION_THRESHOLD; - } - - async compactHistory(force?: boolean): Promise { - const service = getCompactionService(); - const { newHistory, result } = await service.compact(this.history, this, { force }); - - if (result.compacted) { - this.history = newHistory; - this.onCompactionComplete(); - } - - return result; - } } diff --git a/src/lib/executors/claude-executor.ts b/src/lib/executors/claude-executor.ts index 9ecccbd..99fe1bf 100644 --- a/src/lib/executors/claude-executor.ts +++ b/src/lib/executors/claude-executor.ts @@ -30,7 +30,7 @@ export class ClaudeExecutor extends BaseExecutor { return 'jsonl'; } - protected buildArgs(prompt: string, options?: ExecuteOptions): string[] { + protected buildArgs(prompt: string, _options?: ExecuteOptions): string[] { const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose']; // Permission handling: fully auto-approve tool execution. @@ -38,26 +38,12 @@ export class ClaudeExecutor extends BaseExecutor { args.push('--allow-dangerously-skip-permissions'); args.push('--permission-mode', 'bypassPermissions'); - // Session persistence: disable for one-shot, enable for chat - if (options?.oneShot) { - args.push('--no-session-persistence'); - } else if (this.sessionId) { - // Resume session if available (use --resume instead of --session-id) - args.push('--resume', this.sessionId); - } + // Always disable session persistence for command-only execution. + args.push('--no-session-persistence'); return args; } - protected override extractSessionId(data: unknown): string | null { - const msg = data as ClaudeCLIMessage; - return msg.session_id ?? null; - } - - protected override onCompactionComplete(): void { - this.sessionId = null; - } - protected processStreamData( data: unknown, context: StreamContext, diff --git a/src/lib/executors/codex-executor.ts b/src/lib/executors/codex-executor.ts index 442513a..5b4e564 100644 --- a/src/lib/executors/codex-executor.ts +++ b/src/lib/executors/codex-executor.ts @@ -19,42 +19,15 @@ export class CodexExecutor extends BaseExecutor { return 'jsonl'; } - protected buildArgs(prompt: string, options?: ExecuteOptions): string[] { + protected buildArgs(prompt: string, _options?: ExecuteOptions): string[] { const baseArgs = [ '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', ]; - - // Codex CLI does not support log-level flags - - // Session persistence: disable for one-shot, enable for chat - if (options?.oneShot) { - // One-shot mode: no session persistence - return ['exec', prompt, ...baseArgs]; - } - - // Chat mode: resume session if available - if (this.sessionId) { - return ['exec', 'resume', this.sessionId, prompt, ...baseArgs]; - } - return ['exec', prompt, ...baseArgs]; } - protected override extractSessionId(data: unknown): string | null { - const msg = data as CodexCLIMessage; - // Extract thread_id from thread.started message - if (msg.type === 'thread.started' && msg.thread_id) { - return msg.thread_id; - } - return null; - } - - protected override onCompactionComplete(): void { - this.sessionId = null; - } - protected processStreamData( data: unknown, _context: StreamContext, diff --git a/src/lib/executors/copilot-executor.ts b/src/lib/executors/copilot-executor.ts index c9a058c..fb2d1d0 100644 --- a/src/lib/executors/copilot-executor.ts +++ b/src/lib/executors/copilot-executor.ts @@ -3,7 +3,11 @@ * Text-based executor for GitHub Copilot CLI */ import type { AgentMessage, ExecuteOptions } from '../executor'; -import { BaseExecutor as BaseExecutorClass, type StreamParserType } from './base-executor'; +import { + BaseExecutor as BaseExecutorClass, + type StreamContext, + type StreamParserType, +} from './base-executor'; /** * Executor for GitHub Copilot CLI. @@ -12,7 +16,6 @@ import { BaseExecutor as BaseExecutorClass, type StreamParserType } from './base * Features: * - Non-interactive mode with -p flag * - Silent mode to filter out statistics - * - Session resumption with --resume flag * - Tool auto-approval with --allow-all-tools */ export class CopilotExecutor extends BaseExecutorClass { @@ -29,7 +32,7 @@ export class CopilotExecutor extends BaseExecutorClass { return 'text'; } - protected buildArgs(prompt: string, options?: ExecuteOptions): string[] { + protected buildArgs(prompt: string, _options?: ExecuteOptions): string[] { // Start with non-interactive mode const args = ['-p', prompt]; @@ -39,12 +42,6 @@ export class CopilotExecutor extends BaseExecutorClass { // Auto-approve all tools (required for non-interactive mode) args.push('--allow-all-tools'); - // Session resumption for chat mode - // Use --continue to resume the most recent session automatically - if (!options?.oneShot) { - args.push('--continue'); - } - // Debug mode if (this.isDebugMode()) { args.push('--log-level', 'debug'); @@ -55,7 +52,7 @@ export class CopilotExecutor extends BaseExecutorClass { protected processStreamData( data: unknown, - context: { hasYieldedText: boolean; assistantContent: string; count: number }, + context: StreamContext, ): AgentMessage | AgentMessage[] | null { const line = String(data); @@ -105,19 +102,6 @@ export class CopilotExecutor extends BaseExecutorClass { }; } - protected override extractSessionId(_line: string): string | null { - // Copilot CLI in silent mode doesn't output session IDs in stdout - // Session management is handled internally by the CLI - // We could potentially parse from logs or config, but for now - // we rely on --resume without explicit session ID - return null; - } - - protected override onCompactionComplete(): void { - // After compaction, reset session so next execution starts fresh - this.sessionId = null; - } - protected override getInterruptedSuffix(): string { return '\n\n[Interrupted by user]'; } diff --git a/src/lib/executors/cursor-executor.ts b/src/lib/executors/cursor-executor.ts index 33c8a71..2754509 100644 --- a/src/lib/executors/cursor-executor.ts +++ b/src/lib/executors/cursor-executor.ts @@ -34,11 +34,6 @@ export class CursorExecutor extends BaseExecutor { // 프롬프트 추가 args.push(prompt); - // 세션 재개 (chat mode) - if (!options?.oneShot && this.sessionId) { - args.push('--resume', this.sessionId); - } - // Force mode: 자동 승인 args.push('-f'); @@ -53,15 +48,6 @@ export class CursorExecutor extends BaseExecutor { return args; } - protected override extractSessionId(data: unknown): string | null { - const msg = data as CursorCLIMessage; - return msg.session_id ?? null; - } - - protected override onCompactionComplete(): void { - this.sessionId = null; - } - private extractTextDelta(textContent: string, msg: CursorCLIMessage): AgentMessage | null { const delta = extractCumulativeDelta(this.lastTextContent, textContent); diff --git a/src/lib/executors/gemini-executor.ts b/src/lib/executors/gemini-executor.ts index 2f729a0..30553d8 100644 --- a/src/lib/executors/gemini-executor.ts +++ b/src/lib/executors/gemini-executor.ts @@ -7,7 +7,6 @@ import { BaseExecutor, type StreamContext, type StreamParserType } from './base- import { extractCumulativeDelta } from './stream-delta'; import type { GeminiCLIErrorEvent, - GeminiCLIInitEvent, GeminiCLIMessage, GeminiCLIMessageEvent, GeminiCLIResultEvent, @@ -31,7 +30,7 @@ export class GeminiExecutor extends BaseExecutor { return 'jsonl'; } - protected buildArgs(prompt: string, options?: ExecuteOptions): string[] { + protected buildArgs(prompt: string, _options?: ExecuteOptions): string[] { // Use positional argument for prompt (recommended by Gemini CLI) const args = [prompt, '-o', 'stream-json']; @@ -43,11 +42,6 @@ export class GeminiExecutor extends BaseExecutor { args.push('-d'); } - // Session persistence: resume session if available (not in one-shot mode) - if (!options?.oneShot && this.sessionId) { - args.push('-r', this.sessionId); - } - // YOLO mode: auto-approve all tool calls (file edits, shell commands, etc.) // Similar to Claude's acceptEdits mode args.push('-y'); @@ -55,18 +49,6 @@ export class GeminiExecutor extends BaseExecutor { return args; } - protected override extractSessionId(data: unknown): string | null { - const msg = data as GeminiCLIMessage; - if (msg.type === 'init') { - return (msg as GeminiCLIInitEvent).session_id ?? null; - } - return null; - } - - protected override onCompactionComplete(): void { - this.sessionId = null; - } - override async *execute(prompt: string, options?: ExecuteOptions): AsyncGenerator { this.lastTextContent = ''; yield* super.execute(prompt, options); diff --git a/src/lib/executors/opencode-executor.ts b/src/lib/executors/opencode-executor.ts index 06d13e2..d21d358 100644 --- a/src/lib/executors/opencode-executor.ts +++ b/src/lib/executors/opencode-executor.ts @@ -8,7 +8,6 @@ import type { OpenCodeCLIErrorEvent, OpenCodeCLIMessage, OpenCodeCLIMessageEvent, - OpenCodeCLISessionEvent, OpenCodeCLITextEvent, OpenCodeCLIToolCallEvent, OpenCodeCLIToolResultEvent, @@ -40,18 +39,9 @@ export class OpenCodeExecutor extends BaseExecutor { }; } - protected buildArgs(prompt: string, options?: ExecuteOptions): string[] { + protected buildArgs(prompt: string, _options?: ExecuteOptions): string[] { const args = ['run', '--format', 'json']; - // 세션 관리 - if (!options?.oneShot && this.sessionId) { - // 특정 세션 재개 - args.push('-s', this.sessionId); - } else if (!options?.oneShot) { - // 마지막 세션 계속 - args.push('-c'); - } - // 디버그 모드 if (this.isDebugMode()) { args.push('--print-logs', '--log-level', 'DEBUG'); @@ -63,27 +53,6 @@ export class OpenCodeExecutor extends BaseExecutor { return args; } - protected override extractSessionId(data: unknown): string | null { - const msg = data as OpenCodeCLIMessage; - - // sessionID 필드 직접 확인 (모든 메시지에 포함될 수 있음) - if (msg.sessionID) { - return msg.sessionID; - } - - // 세션 시작 메시지 타입 확인 - if (msg.type === 'session' || msg.type === 'start') { - return (msg as OpenCodeCLISessionEvent).sessionID ?? null; - } - - return null; - } - - protected override onCompactionComplete(): void { - // 히스토리 컴팩션 후 세션 리셋 - this.sessionId = null; - } - protected processStreamData( data: unknown, _context: StreamContext, @@ -197,7 +166,6 @@ export class OpenCodeExecutor extends BaseExecutor { } // 완료 메시지는 base class에서 처리 - // 세션 시작 메시지는 sessionID만 추출 return null; } diff --git a/src/lib/services/__tests__/agent-selection-service.test.ts b/src/lib/services/__tests__/agent-selection-service.test.ts index 309943e..26ac556 100644 --- a/src/lib/services/__tests__/agent-selection-service.test.ts +++ b/src/lib/services/__tests__/agent-selection-service.test.ts @@ -4,7 +4,6 @@ import type { Config } from '../../config/schema'; import { DEFAULT_CONFIG } from '../../config/schema'; import { AgentSelectionService } from '../agent-selection-service'; -// Mock agents for testing const mockClaudeAgent: AgentInfo = { name: 'claude', command: 'claude', @@ -23,16 +22,6 @@ const mockCodexAgent: AgentInfo = { sdkPackage: '@openai/codex-sdk', }; -const mockGeminiAgent: AgentInfo = { - name: 'gemini', - command: 'gemini', - displayName: 'Gemini', - description: 'Google Gemini-powered coding assistant', - installUrl: 'https://github.com/google-gemini/gemini-cli', - sdkPackage: '@google/gemini-cli', -}; - -// Mock ConfigManager const createMockConfigManager = (config: Config = DEFAULT_CONFIG) => { let currentConfig = { ...config }; return { @@ -47,11 +36,10 @@ const createMockConfigManager = (config: Config = DEFAULT_CONFIG) => { }; }; -// Mock agents module mock.module('../../agents', () => ({ detectAvailableAgents: mock(async (): Promise => []), getAgentByName: mock((name: string): AgentInfo | undefined => { - const agents = [mockClaudeAgent, mockCodexAgent, mockGeminiAgent]; + const agents = [mockClaudeAgent, mockCodexAgent]; return agents.find((a) => a.name === name); }), })); @@ -62,17 +50,15 @@ describe('AgentSelectionService', () => { let detectAvailableAgents: ReturnType; beforeEach(async () => { - // Reset mocks configManager = createMockConfigManager(); service = new AgentSelectionService(configManager as never); - // Get mocked function const agentsModule = await import('../../agents'); detectAvailableAgents = agentsModule.detectAvailableAgents as ReturnType; }); describe('selectAgent', () => { - test('should return null when no agents available', async () => { + test('should return null when no agents are available', async () => { detectAvailableAgents.mockResolvedValue([]); const result = await service.selectAgent({ mode: 'command' }); @@ -83,38 +69,7 @@ describe('AgentSelectionService', () => { expect(result.availableAgents).toEqual([]); }); - test('should select session agent in interactive mode', async () => { - detectAvailableAgents.mockResolvedValue([mockClaudeAgent, mockCodexAgent]); - - const result = await service.selectAgent({ - mode: 'interactive', - preferredAgentName: 'claude', - }); - - expect(result.agent).toEqual(mockClaudeAgent); - expect(result.source).toBe('session'); - expect(result.needsUserSelection).toBe(false); - }); - - test('should fallback to config agent when session agent is not available', async () => { - detectAvailableAgents.mockResolvedValue([mockCodexAgent]); - configManager = createMockConfigManager({ - ...DEFAULT_CONFIG, - selectedAgent: 'codex', - }); - service = new AgentSelectionService(configManager as never); - - const result = await service.selectAgent({ - mode: 'interactive', - preferredAgentName: 'claude', // Not available - }); - - expect(result.agent).toEqual(mockCodexAgent); - expect(result.source).toBe('config'); - expect(result.needsUserSelection).toBe(false); - }); - - test('should select config agent when no session agent', async () => { + test('should select config agent when valid', async () => { detectAvailableAgents.mockResolvedValue([mockClaudeAgent, mockCodexAgent]); configManager = createMockConfigManager({ ...DEFAULT_CONFIG, @@ -129,7 +84,7 @@ describe('AgentSelectionService', () => { expect(result.needsUserSelection).toBe(false); }); - test('should auto-select first agent in command mode when no config', async () => { + test('should auto-select first agent when config is missing', async () => { detectAvailableAgents.mockResolvedValue([mockClaudeAgent, mockCodexAgent]); const result = await service.selectAgent({ mode: 'command' }); @@ -139,55 +94,6 @@ describe('AgentSelectionService', () => { expect(result.needsUserSelection).toBe(false); }); - test('should auto-select single agent in interactive mode', async () => { - detectAvailableAgents.mockResolvedValue([mockClaudeAgent]); - - const result = await service.selectAgent({ mode: 'interactive' }); - - expect(result.agent).toEqual(mockClaudeAgent); - expect(result.source).toBe('auto'); - expect(result.needsUserSelection).toBe(false); - }); - - test('should indicate user selection needed in interactive mode with multiple agents', async () => { - detectAvailableAgents.mockResolvedValue([mockClaudeAgent, mockCodexAgent, mockGeminiAgent]); - - const result = await service.selectAgent({ - mode: 'interactive', - allowPrompt: true, - }); - - expect(result.agent).toBeNull(); - expect(result.source).toBe('user'); - expect(result.needsUserSelection).toBe(true); - expect(result.availableAgents).toHaveLength(3); - }); - - test('should auto-select first agent when prompt not allowed in interactive mode', async () => { - detectAvailableAgents.mockResolvedValue([mockClaudeAgent, mockCodexAgent]); - - const result = await service.selectAgent({ - mode: 'interactive', - allowPrompt: false, - }); - - expect(result.agent).toEqual(mockClaudeAgent); - expect(result.source).toBe('auto'); - expect(result.needsUserSelection).toBe(false); - }); - - test('should ignore invalid session agent name', async () => { - detectAvailableAgents.mockResolvedValue([mockClaudeAgent]); - - const result = await service.selectAgent({ - mode: 'interactive', - preferredAgentName: 'invalid-agent', - }); - - expect(result.agent).toEqual(mockClaudeAgent); - expect(result.source).toBe('auto'); - }); - test('should ignore invalid config agent name', async () => { detectAvailableAgents.mockResolvedValue([mockClaudeAgent]); configManager = createMockConfigManager({ @@ -241,23 +147,6 @@ describe('AgentSelectionService', () => { }); describe('priority order', () => { - test('should prioritize session over config', async () => { - detectAvailableAgents.mockResolvedValue([mockClaudeAgent, mockCodexAgent]); - configManager = createMockConfigManager({ - ...DEFAULT_CONFIG, - selectedAgent: 'codex', - }); - service = new AgentSelectionService(configManager as never); - - const result = await service.selectAgent({ - mode: 'interactive', - preferredAgentName: 'claude', - }); - - expect(result.agent).toEqual(mockClaudeAgent); - expect(result.source).toBe('session'); - }); - test('should prioritize config over auto-selection', async () => { detectAvailableAgents.mockResolvedValue([mockClaudeAgent, mockCodexAgent]); configManager = createMockConfigManager({ diff --git a/src/lib/services/__tests__/transfer-service.test.ts b/src/lib/services/__tests__/transfer-service.test.ts deleted file mode 100644 index c0e6003..0000000 --- a/src/lib/services/__tests__/transfer-service.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { createTestEnvironment, FIXTURES, type TestEnvironment } from '../../__tests__/test-utils'; -import { getAgentDisplayName, type TransferAgent, transferToAgent } from '../transfer-service'; - -describe('getAgentDisplayName', () => { - test('should return "Claude Code" for claude', () => { - expect(getAgentDisplayName('claude')).toBe('Claude Code'); - }); - - test('should return "Codex" for codex', () => { - expect(getAgentDisplayName('codex')).toBe('Codex'); - }); - - test('should return agent name as-is for unknown agent', () => { - expect(getAgentDisplayName('unknown' as unknown as TransferAgent)).toBe('unknown'); - }); -}); - -describe('transferToAgent', () => { - let testEnv: TestEnvironment; - - beforeEach(async () => { - testEnv = await createTestEnvironment(); - }); - - afterEach(async () => { - await testEnv.cleanup(); - }); - - test('should fail when agent CLI is not available', async () => { - // Most test environments won't have claude or codex CLI installed - // We can't easily mock the spawn check, so we verify the error handling - const result = await transferToAgent(FIXTURES.shortHistory, { - agent: 'claude', - }); - - // Either it succeeds (if claude is installed) or fails with proper error - if (!result.success) { - expect(result.error).toContain('CLI is not installed or not in PATH'); - } - }); - - test('should return proper error message for missing codex CLI', async () => { - const result = await transferToAgent(FIXTURES.shortHistory, { - agent: 'codex', - }); - - if (!result.success) { - expect(result.error).toContain('codex CLI is not installed or not in PATH'); - } - }); -}); - -describe('formatHistoryAsMarkdown (via transferToAgent output)', () => { - // We test the markdown formatting indirectly through the saved file - // These tests require the CLI to be available or we mock at a different level - - test('empty history should indicate no conversation', () => { - // Since formatHistoryAsMarkdown is private, we can test its behavior - // by checking that empty history handling is correct - // This is tested implicitly through the service behavior - expect(FIXTURES.shortHistory.length).toBeGreaterThan(0); - }); - - test('history should have alternating roles', () => { - expect(FIXTURES.shortHistory[0].role).toBe('user'); - expect(FIXTURES.shortHistory[1].role).toBe('assistant'); - }); -}); - -describe('TransferService integration', () => { - test('transferToAgent should accept workingDirectory option', async () => { - const result = await transferToAgent(FIXTURES.shortHistory, { - agent: 'claude', - workingDirectory: '/tmp/test-project', - }); - - // Verify the function accepts the workingDirectory option - // without error (even if CLI is not available) - expect(result).toBeDefined(); - expect(typeof result.success).toBe('boolean'); - }); - - test('result should include command when successful', async () => { - const result = await transferToAgent(FIXTURES.shortHistory, { - agent: 'claude', - }); - - if (result.success) { - expect(result.command).toBeDefined(); - expect(result.command).toContain('claude'); - expect(result.command).toContain('cat'); - expect(result.sessionFile).toBeDefined(); - } - }); - - test('result should include sessionFile path when successful', async () => { - const result = await transferToAgent(FIXTURES.shortHistory, { - agent: 'claude', - }); - - if (result.success) { - expect(result.sessionFile).toBeDefined(); - expect(result.sessionFile).toContain('clix'); - expect(result.sessionFile).toContain('session-'); - expect(result.sessionFile).toContain('.md'); - } - }); -}); - -describe('TransferAgent type', () => { - test('should accept "claude" as valid agent', () => { - const agent: TransferAgent = 'claude'; - expect(agent).toBe('claude'); - }); - - test('should accept "codex" as valid agent', () => { - const agent: TransferAgent = 'codex'; - expect(agent).toBe('codex'); - }); -}); diff --git a/src/lib/services/agent-selection-service.ts b/src/lib/services/agent-selection-service.ts index 6504207..c045e3b 100644 --- a/src/lib/services/agent-selection-service.ts +++ b/src/lib/services/agent-selection-service.ts @@ -2,21 +2,14 @@ import type { AgentInfo } from '../agents'; import { detectAvailableAgents, getAgentByName } from '../agents'; import type { ConfigManager } from '../config'; -/** - * Agent selection mode. - * - command: Single execution mode (e.g., `clix install`) - * - interactive: Persistent conversation mode (e.g., `clix`) - */ -export type SelectionMode = 'command' | 'interactive'; +export type SelectionMode = 'command'; /** * Source of the selected agent. - * - session: Selected from session (interactive mode only) * - config: Selected from config's selectedAgent field - * - auto: Auto-selected (first available or single agent) - * - user: User manually selected via UI + * - auto: Auto-selected (first available) */ -export type SelectionSource = 'session' | 'config' | 'auto' | 'user'; +export type SelectionSource = 'config' | 'auto'; /** * Options for agent selection. @@ -24,12 +17,6 @@ export type SelectionSource = 'session' | 'config' | 'auto' | 'user'; export interface SelectionOptions { /** Selection mode */ mode: SelectionMode; - /** Session ID for session-based selection (interactive mode only) */ - sessionId?: string; - /** Whether to allow prompting user for selection */ - allowPrompt?: boolean; - /** Preferred agent name from session (interactive mode only) */ - preferredAgentName?: string; } /** @@ -40,7 +27,7 @@ export interface SelectionResult { agent: AgentInfo | null; /** Source of the selection */ source: SelectionSource; - /** Whether user selection is needed (when allowPrompt is true) */ + /** Always false in command-only mode */ needsUserSelection: boolean; /** Available agents for selection */ availableAgents: AgentInfo[]; @@ -48,7 +35,7 @@ export interface SelectionResult { /** * Service for centralized agent selection logic. - * Handles agent selection across command and interactive modes with consistent behavior. + * Handles agent selection for command execution. */ export class AgentSelectionService { constructor(private readonly configManager: ConfigManager) {} @@ -56,15 +43,13 @@ export class AgentSelectionService { /** * Select an agent based on the provided options. * Priority order: - * 1. Session agent (interactive mode only) - * 2. Config's selectedAgent - * 3. Auto-select (single agent or first available in command mode) - * 4. User selection (interactive mode with multiple agents) + * 1. Config's selectedAgent + * 2. Auto-select first available agent * * @param options - Selection options * @returns Selection result */ - async selectAgent(options: SelectionOptions): Promise { + async selectAgent(_options: SelectionOptions): Promise { const availableAgents = await detectAvailableAgents(); // No agents available @@ -77,20 +62,7 @@ export class AgentSelectionService { }; } - // 1. Try session agent (interactive mode only) - if (options.mode === 'interactive' && options.preferredAgentName) { - const sessionAgent = this.getValidAgent(options.preferredAgentName, availableAgents); - if (sessionAgent) { - return { - agent: sessionAgent, - source: 'session', - needsUserSelection: false, - availableAgents, - }; - } - } - - // 2. Try config's selectedAgent + // 1. Try config's selectedAgent const config = await this.configManager.load(); if (config.selectedAgent) { const configAgent = this.getValidAgent(config.selectedAgent, availableAgents); @@ -104,39 +76,7 @@ export class AgentSelectionService { } } - // 3. Auto-select - // Command mode: Always auto-select first available agent - if (options.mode === 'command') { - const agent = availableAgents[0]; - return { - agent, - source: 'auto', - needsUserSelection: false, - availableAgents, - }; - } - - // Interactive mode: Auto-select only if single agent - if (availableAgents.length === 1) { - return { - agent: availableAgents[0], - source: 'auto', - needsUserSelection: false, - availableAgents, - }; - } - - // 4. User selection needed (interactive mode with multiple agents) - if (options.allowPrompt) { - return { - agent: null, - source: 'user', - needsUserSelection: true, - availableAgents, - }; - } - - // Fallback: First available agent + // 2. Auto-select first available agent return { agent: availableAgents[0], source: 'auto', diff --git a/src/lib/services/history-compaction.ts b/src/lib/services/history-compaction.ts deleted file mode 100644 index 8237569..0000000 --- a/src/lib/services/history-compaction.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { AgentExecutor, ConversationMessage } from '../executor'; - -/** - * Claude Sonnet 4.5 context window: 200,000 tokens - * - * Compaction threshold: 90% of context window - * (Claude Code triggers auto-compact when context is "near limit") - * - * Using character count as a proxy for tokens (roughly 4 chars per token). - * 200K tokens * 0.9 * 4 chars/token = 720,000 chars - */ -export const CLAUDE_SONNET_CONTEXT_WINDOW = 200000; // tokens -export const COMPRESSION_THRESHOLD_RATIO = 0.9; // 90% of context window (Claude Code style) -export const CHARS_PER_TOKEN = 4; // approximate - -export const DEFAULT_COMPACTION_THRESHOLD = - CLAUDE_SONNET_CONTEXT_WINDOW * COMPRESSION_THRESHOLD_RATIO * CHARS_PER_TOKEN; // 720,000 chars - -/** - * Fraction of recent history to preserve during compaction. - * 0.3 means keep the last 30% of history intact. - */ -export const COMPACTION_PRESERVE_RATIO = 0.3; - -/** - * Maximum summary length in characters. - */ -export const MAX_SUMMARY_LENGTH = 4000; - -export interface CompactionResult { - compacted: boolean; - originalLength: number; - newLength: number; - messagesCompacted: number; - messagesPreserved: number; -} - -export interface CompactionOptions { - force?: boolean; - threshold?: number; - preserveRatio?: number; -} - -/** - * Calculates the total character count of the history. - */ -export function calculateHistorySize(history: ConversationMessage[]): number { - return history.reduce((acc, msg) => acc + msg.content.length, 0); -} - -/** - * Finds the split point for compaction. - * Returns the index where we should start preserving messages. - */ -export function findCompactionSplitPoint( - history: ConversationMessage[], - preserveRatio: number, -): number { - if (history.length === 0) return 0; - if (preserveRatio <= 0 || preserveRatio >= 1) { - throw new Error('Preserve ratio must be between 0 and 1'); - } - - const charCounts = history.map((msg) => msg.content.length); - const totalChars = charCounts.reduce((a, b) => a + b, 0); - const targetCharsToCompress = totalChars * (1 - preserveRatio); - - let cumulativeChars = 0; - let lastValidSplitPoint = 0; - - for (let i = 0; i < history.length; i++) { - // Only split on user messages to maintain conversation pairs - if (history[i].role === 'user') { - if (cumulativeChars >= targetCharsToCompress) { - return i; - } - lastValidSplitPoint = i; - } - cumulativeChars += charCounts[i]; - } - - // If we couldn't find a good split point after threshold, use last valid one - return lastValidSplitPoint; -} - -/** - * Generates a compression prompt for the agent to summarize history. - */ -export function generateCompressionPrompt(historyToCompress: ConversationMessage[]): string { - const conversationText = historyToCompress - .map((msg) => `[${msg.role.toUpperCase()}]: ${msg.content}`) - .join('\n\n'); - - return `You are a conversation summarizer. Your task is to create a concise summary of the following conversation that captures: -1. Key topics discussed -2. Important decisions made -3. Any code or technical details mentioned -4. User preferences or requirements expressed - -Keep the summary under ${MAX_SUMMARY_LENGTH} characters. Focus on information that would be useful for continuing the conversation. - - -${conversationText} - - -Provide only the summary, no additional commentary.`; -} - -/** - * Service for compacting conversation history when it gets too long. - */ -export class HistoryCompactionService { - private threshold: number; - private preserveRatio: number; - - constructor(options?: { threshold?: number; preserveRatio?: number }) { - this.threshold = options?.threshold ?? DEFAULT_COMPACTION_THRESHOLD; - this.preserveRatio = options?.preserveRatio ?? COMPACTION_PRESERVE_RATIO; - } - - /** - * Checks if the history needs compaction based on size. - */ - needsCompaction(history: ConversationMessage[]): boolean { - return calculateHistorySize(history) >= this.threshold; - } - - /** - * Compacts the history by summarizing older messages. - * Returns the new compacted history. - */ - async compact( - history: ConversationMessage[], - executor: AgentExecutor, - options?: CompactionOptions, - ): Promise<{ newHistory: ConversationMessage[]; result: CompactionResult }> { - const originalSize = calculateHistorySize(history); - const threshold = options?.threshold ?? this.threshold; - const preserveRatio = options?.preserveRatio ?? this.preserveRatio; - - // Check if compaction is needed - if (!options?.force && originalSize < threshold) { - return { - newHistory: history, - result: { - compacted: false, - originalLength: originalSize, - newLength: originalSize, - messagesCompacted: 0, - messagesPreserved: history.length, - }, - }; - } - - // Don't compact if history is too short - if (history.length < 4) { - return { - newHistory: history, - result: { - compacted: false, - originalLength: originalSize, - newLength: originalSize, - messagesCompacted: 0, - messagesPreserved: history.length, - }, - }; - } - - // Find split point - const splitPoint = findCompactionSplitPoint(history, preserveRatio); - - if (splitPoint === 0) { - return { - newHistory: history, - result: { - compacted: false, - originalLength: originalSize, - newLength: originalSize, - messagesCompacted: 0, - messagesPreserved: history.length, - }, - }; - } - - const historyToCompress = history.slice(0, splitPoint); - const historyToKeep = history.slice(splitPoint); - - // Generate summary using the executor - const compressionPrompt = generateCompressionPrompt(historyToCompress); - let summary = ''; - - try { - // Temporarily clear history to avoid recursion - const savedHistory = executor.getHistory(); - executor.clearHistory(); - - for await (const message of executor.execute(compressionPrompt)) { - if (message.type === 'text') { - summary += message.content; - } - } - - // Restore original history - executor.setHistory(savedHistory); - } catch (error) { - // If summarization fails, return original history - console.error('Compaction failed:', error); - return { - newHistory: history, - result: { - compacted: false, - originalLength: originalSize, - newLength: originalSize, - messagesCompacted: 0, - messagesPreserved: history.length, - }, - }; - } - - // Truncate summary if too long - if (summary.length > MAX_SUMMARY_LENGTH) { - summary = `${summary.slice(0, MAX_SUMMARY_LENGTH)}...`; - } - - // Create new compacted history - const newHistory: ConversationMessage[] = [ - { - role: 'user', - content: `[Previous conversation summary]\n${summary}`, - }, - { - role: 'assistant', - content: 'Understood. I have the context from our previous conversation.', - }, - ...historyToKeep, - ]; - - const newSize = calculateHistorySize(newHistory); - - return { - newHistory, - result: { - compacted: true, - originalLength: originalSize, - newLength: newSize, - messagesCompacted: historyToCompress.length, - messagesPreserved: historyToKeep.length, - }, - }; - } -} - -// Singleton instance -let compactionService: HistoryCompactionService | null = null; - -export function getCompactionService(): HistoryCompactionService { - if (!compactionService) { - compactionService = new HistoryCompactionService(); - } - return compactionService; -} diff --git a/src/lib/services/session-store.ts b/src/lib/services/session-store.ts deleted file mode 100644 index 44ee2bb..0000000 --- a/src/lib/services/session-store.ts +++ /dev/null @@ -1,208 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import type { ChatMessage } from '@/ui/chat/context/ChatContext'; -import { findProjectRoot } from '../utils/path'; - -export const CHAT_SESSION_SCHEMA_VERSION = 1; - -export const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; - -export interface PersistedChatMessage { - id: string; - role: ChatMessage['role']; - content: string; - timestamp: number; - status?: ChatMessage['status']; - toolName?: string; - // bash-specific fields - bashCommand?: string; - bashExitCode?: number | null; - bashTruncated?: boolean; -} - -export interface PersistedChatSessionV1 { - version: typeof CHAT_SESSION_SCHEMA_VERSION; - id: string; - createdAt: number; - updatedAt: number; - currentAgentName: string | null; - messages: PersistedChatMessage[]; - inputHistory: string[]; - agentSessions: Record; -} - -export type PersistedChatSession = PersistedChatSessionV1; - -export interface ChatSessionSummary { - id: string; - updatedAt: number; - currentAgentName: string | null; - preview: string; -} - -function resolveSessionsDir(): string { - // Allow tests to override the location. - if (process.env.CLIX_SESSION_DIR) { - return process.env.CLIX_SESSION_DIR; - } - return path.join(findProjectRoot(), '.clix', 'sessions'); -} - -async function ensureSessionsDir(): Promise { - const dir = resolveSessionsDir(); - await fs.mkdir(dir, { recursive: true }); - return dir; -} - -function sessionFilePath(sessionId: string): string { - return path.join(resolveSessionsDir(), `${sessionId}.json`); -} - -export function createChatSessionId(): string { - const rand = Math.random().toString(36).slice(2, 10); - return `sess-${Date.now()}-${rand}`; -} - -export function serializeChatMessages(messages: ChatMessage[]): PersistedChatMessage[] { - return messages.map((m) => ({ - id: m.id, - role: m.role, - content: m.content, - timestamp: m.timestamp.getTime(), - status: m.status, - toolName: m.toolName, - bashCommand: m.bashCommand, - bashExitCode: m.bashExitCode, - bashTruncated: m.bashTruncated, - })); -} - -export function deserializeChatMessages(messages: PersistedChatMessage[]): ChatMessage[] { - return messages.map((m) => ({ - id: m.id, - role: m.role, - content: m.content, - timestamp: new Date(m.timestamp), - status: m.status, - toolName: m.toolName, - bashCommand: m.bashCommand, - bashExitCode: m.bashExitCode, - bashTruncated: m.bashTruncated, - })); -} - -export async function saveChatSession(session: PersistedChatSession): Promise { - const dir = await ensureSessionsDir(); - const filePath = path.join(dir, `${session.id}.json`); - await fs.writeFile(filePath, `${JSON.stringify(session, null, 2)}\n`, 'utf8'); -} - -export async function loadChatSession(sessionId: string): Promise { - try { - const raw = await fs.readFile(sessionFilePath(sessionId), 'utf8'); - const parsed = JSON.parse(raw) as PersistedChatSession; - if (parsed.version !== CHAT_SESSION_SCHEMA_VERSION) return null; - return parsed; - } catch { - return null; - } -} - -export async function listChatSessionIds(): Promise { - try { - const dir = await ensureSessionsDir(); - const files = await fs.readdir(dir); - return files - .filter((f) => f.endsWith('.json')) - .map((f) => f.replace(/\.json$/, '')) - .sort(); - } catch { - return []; - } -} - -export async function loadLatestChatSession( - maxAgeMs: number = ONE_WEEK_MS, -): Promise { - const ids = await listChatSessionIds(); - if (ids.length === 0) return null; - - const now = Date.now(); - - const sessions = await Promise.all(ids.map((id) => loadChatSession(id))); - const candidates = sessions - .filter((s): s is PersistedChatSession => !!s) - .filter((s) => now - s.updatedAt <= maxAgeMs); - - if (candidates.length === 0) return null; - - candidates.sort((a, b) => b.updatedAt - a.updatedAt); - return candidates[0] ?? null; -} - -export async function cleanupOldChatSessions( - maxAgeMs: number = ONE_WEEK_MS, -): Promise<{ deleted: number }> { - const dir = await ensureSessionsDir(); - const ids = await listChatSessionIds(); - const now = Date.now(); - - let deleted = 0; - - for (const id of ids) { - const session = await loadChatSession(id); - if (!session) continue; - if (now - session.updatedAt <= maxAgeMs) continue; - - try { - await fs.unlink(path.join(dir, `${id}.json`)); - deleted++; - } catch { - // ignore - } - } - - return { deleted }; -} - -function buildPreviewFromMessages(messages: PersistedChatMessage[]): string { - const trimmedMessages = messages - .map((m) => ({ role: m.role, content: (m.content ?? '').trim() })) - .filter((m) => m.content.length > 0); - - const lastUser = [...trimmedMessages].reverse().find((m) => m.role === 'user'); - const candidate = lastUser ?? trimmedMessages[trimmedMessages.length - 1]; - if (!candidate) return ''; - - // Collapse whitespace and keep it one-line. - return candidate.content.replace(/\s+/g, ' '); -} - -export async function listChatSessions( - maxAgeMs: number = ONE_WEEK_MS, -): Promise { - await cleanupOldChatSessions(maxAgeMs); - - const ids = await listChatSessionIds(); - if (ids.length === 0) return []; - - const now = Date.now(); - const sessions = await Promise.all(ids.map((id) => loadChatSession(id))); - - const summaries: ChatSessionSummary[] = []; - - for (const session of sessions) { - if (!session) continue; - if (now - session.updatedAt > maxAgeMs) continue; - - summaries.push({ - id: session.id, - updatedAt: session.updatedAt, - currentAgentName: session.currentAgentName, - preview: buildPreviewFromMessages(session.messages), - }); - } - - summaries.sort((a, b) => b.updatedAt - a.updatedAt); - return summaries; -} diff --git a/src/lib/services/transfer-service.ts b/src/lib/services/transfer-service.ts deleted file mode 100644 index 777cca8..0000000 --- a/src/lib/services/transfer-service.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { spawn } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import type { ConversationMessage } from '../executor'; -import { findProjectRoot, formatPath } from '../utils/path'; - -export type TransferAgent = 'claude' | 'codex'; - -export interface TransferOptions { - agent: TransferAgent; - workingDirectory?: string; -} - -/** - * Formats conversation history as markdown for saving to file. - * Includes instruction for the agent to continue the conversation. - */ -function formatHistoryAsMarkdown(history: ConversationMessage[]): string { - const timestamp = new Date().toISOString(); - let markdown = `# Clix CLI Session\n\n_Saved: ${timestamp}_\n\n---\n\n`; - - if (history.length === 0) { - markdown += '_No conversation history_\n\n'; - } else { - for (const msg of history) { - const role = msg.role === 'user' ? '👤 User' : '🤖 Assistant'; - markdown += `## ${role}\n\n${msg.content}\n\n`; - } - } - - // Add instruction for the agent - markdown += `---\n\nPlease review the conversation history above and continue from where we left off.\n`; - - return markdown; -} - -/** - * Saves conversation history to .clix directory. - * Returns the path to the saved file. - */ -async function saveSessionHistory(history: ConversationMessage[]): Promise { - const clixDir = join(findProjectRoot(), '.clix'); - - // Ensure .clix directory exists - try { - await mkdir(clixDir, { recursive: true }); - } catch (_error) { - // Directory might already exist, ignore error - } - - // Generate session filename with timestamp - const timestamp = Date.now(); - const sessionFile = join(clixDir, `session-${timestamp}.md`); - - // Format and save history - const markdown = formatHistoryAsMarkdown(history); - await writeFile(sessionFile, markdown, 'utf-8'); - - return sessionFile; -} - -/** - * Checks if the target agent CLI is available. - */ -async function isAgentCLIAvailable(agent: TransferAgent): Promise { - return new Promise((resolve) => { - const command = agent === 'claude' ? 'which claude' : 'which codex'; - const child = spawn('sh', ['-c', command], { stdio: 'pipe' }); - - child.on('close', (code) => { - resolve(code === 0); - }); - - child.on('error', () => { - resolve(false); - }); - }); -} - -export interface TransferResult { - success: boolean; - error?: string; - sessionFile?: string; - command?: string; -} - -/** - * Prepares transfer by saving session history and generating command. - * Returns the command for the user to execute manually. - */ -export async function transferToAgent( - history: ConversationMessage[], - options: TransferOptions, -): Promise { - const { agent } = options; - - // Check if target CLI is available - const isAvailable = await isAgentCLIAvailable(agent); - if (!isAvailable) { - return { - success: false, - error: `${agent} CLI is not installed or not in PATH.`, - }; - } - - // Save session history - let sessionFile: string; - try { - sessionFile = await saveSessionHistory(history); - } catch (error) { - return { - success: false, - error: `Failed to save session: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - - // Build command for user to execute - // Session file already includes the instruction to continue the conversation - const command = `${agent} "$(cat '${sessionFile}')"`; - - return { - success: true, - sessionFile: formatPath(sessionFile), - command, - }; -} - -/** - * Gets the display name for an agent. - */ -export function getAgentDisplayName(agent: TransferAgent): string { - switch (agent) { - case 'claude': - return 'Claude Code'; - case 'codex': - return 'Codex'; - default: - return agent; - } -} diff --git a/src/lib/services/vercel-skills.ts b/src/lib/services/vercel-skills.ts new file mode 100644 index 0000000..292949a --- /dev/null +++ b/src/lib/services/vercel-skills.ts @@ -0,0 +1,110 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import open from 'open'; +import type { AgentInfo } from '@/lib/agents'; + +const VERCEL_SKILLS_REPO_URL = 'https://github.com/vercel-labs/skills'; +const VERCEL_AGENT_SKILLS_REPO = 'vercel-labs/agent-skills'; + +interface SkillsAgentMapping { + skillsAgentId: string; + projectPath: string[]; + globalPath: string[]; +} + +const AGENT_SKILLS_MAPPINGS: Record = { + claude: { + skillsAgentId: 'claude-code', + projectPath: ['.claude', 'skills'], + globalPath: ['.claude', 'skills'], + }, + codex: { + skillsAgentId: 'codex', + projectPath: ['.agents', 'skills'], + globalPath: ['.codex', 'skills'], + }, + gemini: { + skillsAgentId: 'gemini-cli', + projectPath: ['.agents', 'skills'], + globalPath: ['.gemini', 'skills'], + }, + opencode: { + skillsAgentId: 'opencode', + projectPath: ['.agents', 'skills'], + globalPath: ['.config', 'opencode', 'skills'], + }, + cursor: { + skillsAgentId: 'cursor', + projectPath: ['.agents', 'skills'], + globalPath: ['.cursor', 'skills'], + }, + copilot: { + skillsAgentId: 'github-copilot', + projectPath: ['.agents', 'skills'], + globalPath: ['.copilot', 'skills'], + }, +}; + +function resolveAgentMapping(agentName: string): SkillsAgentMapping { + return ( + AGENT_SKILLS_MAPPINGS[agentName] ?? { + skillsAgentId: 'codex', + projectPath: ['.agents', 'skills'], + globalPath: ['.config', 'agents', 'skills'], + } + ); +} + +function hasInstalledSkillsAtPath(path: string): boolean { + if (!existsSync(path)) { + return false; + } + + const entries = readdirSync(path, { withFileTypes: true }); + return entries.some((entry) => entry.isDirectory() && !entry.name.startsWith('.')); +} + +export function hasVercelSkillsInstalled( + agentName: string, + projectPath: string = process.cwd(), +): boolean { + const mapping = resolveAgentMapping(agentName); + const projectSkillsPath = join(projectPath, ...mapping.projectPath); + const globalSkillsPath = join(homedir(), ...mapping.globalPath); + + return hasInstalledSkillsAtPath(projectSkillsPath) || hasInstalledSkillsAtPath(globalSkillsPath); +} + +export async function ensureVercelSkillsInstalled( + agent: AgentInfo, + commandName: string, + projectPath: string = process.cwd(), +): Promise { + if (hasVercelSkillsInstalled(agent.name, projectPath)) { + return true; + } + + const mapping = resolveAgentMapping(agent.name); + const installCommand = `npx skills add ${VERCEL_AGENT_SKILLS_REPO} --agent ${mapping.skillsAgentId}`; + + console.error(''); + console.error( + `Vercel Skills is required before running '${commandName}' with ${agent.displayName}.`, + ); + console.error('Open the repository guide and install skills, then retry this command:'); + console.error(` ${installCommand}`); + console.error(''); + console.error('After installation completes, run the same clix command again.'); + + try { + await open(VERCEL_SKILLS_REPO_URL); + console.error(`Opened ${VERCEL_SKILLS_REPO_URL} in your browser.`); + } catch { + console.error( + `Could not open browser automatically. Open this URL manually: ${VERCEL_SKILLS_REPO_URL}`, + ); + } + + return false; +} diff --git a/src/lib/skills.ts b/src/lib/skills.ts index f301d92..d752e29 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -1,33 +1,18 @@ import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import type { PreparationContext } from '@/commands/skill/preparation'; -import { - EMBEDDED_SKILL_METADATA, - getEmbeddedSkill, - getSkillMetadataByCommand, - hasEmbeddedSkills, - type SkillMetadata, -} from './embedded-skills'; +import { getEmbeddedSkill, hasEmbeddedSkills } from './embedded-skills'; import type { AgentExecutor, AgentMessage } from './executor'; -import { getDebugPrompt } from './services/debug-service'; import { formatProjectType } from './services/project-detector'; -/** - * Skill type - dynamically generated from embedded skills + local skills. - * Use getAvailableSkillTypes() for runtime access. - */ export type SkillType = string; export interface SkillOptions { projectPath?: string; platform?: 'ios' | 'android' | 'react-native' | 'flutter'; signal?: AbortSignal; - /** One-shot mode: disable session persistence (for command-line execution) */ oneShot?: boolean; - /** Preparation context from install preparation phase */ preparationContext?: PreparationContext; - /** Install phase for /install runtime tasks */ installPhase?: 'project-build' | 'integration'; } @@ -35,140 +20,50 @@ export interface SkillInfo { type: SkillType; name: string; 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; - /** Visibility in user-facing command lists/help. */ visibility?: 'public' | 'internal'; } -/** - * Local skills that are not provided by @clix-so/clix-agent-skills. - * These are implemented directly in this file. - */ const LOCAL_SKILLS: SkillInfo[] = [ { type: 'install', name: 'SDK Installation', description: 'Autonomous SDK integration with automatic file modifications', isLocal: true, + visibility: 'public', }, { type: 'doctor', name: 'SDK Doctor', description: 'Check Clix SDK integration status', isLocal: true, - }, - { - type: 'debug', - name: 'Debug Assistant', - description: 'Interactive debugging assistant', - isLocal: true, + visibility: 'public', }, ]; -/** - * Get all skills including internal-only skills. - */ -function getAllSkills(): SkillInfo[] { - const embeddedSkills: SkillInfo[] = EMBEDDED_SKILL_METADATA.map((meta) => ({ - type: meta.commandName, - name: meta.displayName, - description: meta.shortDescription || meta.displayName, - isLocal: false, - visibility: 'public', - })); - - return [...embeddedSkills, ...LOCAL_SKILLS]; -} - -/** - * Get user-facing skills (from embedded metadata + public local skills). - */ export function getAvailableSkills(): SkillInfo[] { - return getAllSkills().filter((skill) => skill.visibility !== 'internal'); + return LOCAL_SKILLS; } -/** - * Available skills - for backward compatibility. - * Prefer using getAvailableSkills() for dynamic access. - */ export const AVAILABLE_SKILLS: SkillInfo[] = getAvailableSkills(); -/** - * Get all available skill types (command names). - */ export function getAvailableSkillTypes(): string[] { return getAvailableSkills().map((s) => s.type); } -/** - * Check if a skill type is valid. - */ export function isValidSkillType(type: string): boolean { return getAvailableSkillTypes().includes(type); } export function getSkillInfo(type: SkillType): SkillInfo | undefined { - return getAllSkills().find((skill) => skill.type === type); -} - -/** - * Get skill metadata by command name. - */ -function getSkillFolderByCommand(commandName: string): string | null { - const meta = getSkillMetadataByCommand(commandName); - return meta?.folder ?? null; + return getAvailableSkills().find((skill) => skill.type === type); } -/** - * Check if a skill is a local skill (not from package). - */ function isLocalSkill(skillType: SkillType): boolean { - return getAllSkills().some((skill) => skill.type === skillType && skill.isLocal); -} - -function getSkillsPackagePath(): string | null { - try { - const require = createRequire(import.meta.url); - const packagePath = require.resolve('@clix-so/clix-agent-skills/package.json'); - return dirname(packagePath); - } catch { - return null; - } + return getAvailableSkills().some((skill) => skill.type === skillType && skill.isLocal); } -function readSkillMarkdown(skillFolder: string): string { - // Try external package first (for development) - const packagePath = getSkillsPackagePath(); - if (packagePath) { - try { - const skillPath = join(packagePath, 'skills', skillFolder, 'SKILL.md'); - return readFileSync(skillPath, 'utf-8'); - } catch { - // Fall through to embedded skills - } - } - - // Fall back to embedded skills (for bundled binary) - if (hasEmbeddedSkills()) { - const embeddedSkill = getEmbeddedSkill(skillFolder); - if (embeddedSkill) { - return embeddedSkill; - } - } - - // No skills available - throw new Error( - `Skill "${skillFolder}" not found. Skills package not installed and no embedded skills available.`, - ); -} - -/** - * One-shot mode instruction with explicit file modification permissions. - * Instructs the agent to actually make changes rather than providing manual steps. - */ const ONE_SHOT_INSTRUCTION = ` IMPORTANT: This is a non-interactive one-shot execution with FULL file modification permissions. @@ -193,35 +88,7 @@ COMPLETION CRITERIA: - Provide a summary of changes made (not what needs to be done manually) `; -/** - * Interactive mode instruction for Guided Interactive Workflow. - * Instructs the agent to follow the Confirm → Propose → Validate → Implement → Verify pattern. - */ -const INTERACTIVE_MODE_INSTRUCTION = ` -IMPORTANT: This is an interactive conversation session. Follow the Guided Interactive Workflow. - -EXECUTION GUIDELINES: -- ALWAYS follow the workflow steps in order: Confirm → Propose → Validate → Implement → Verify -- DO NOT skip to implementation without completing earlier steps -- ASK for required inputs before proceeding (platform, goals, preferences) -- PROPOSE your plan and wait for user approval before making changes -- NEVER modify files without explicit user confirmation -- If information is missing, ASK the user rather than assuming - -WORKFLOW ENFORCEMENT: -- Start by confirming the minimum required inputs from the user -- Present your proposed plan for review -- Only proceed to implementation after the user approves the plan -- Validate before implementing, verify after implementing -`; - -/** - * Read a local skill prompt from the skills directory. - * Local skill prompts are stored in src/lib/skills//SKILL.md - * For bundled builds, prompts are embedded at build time. - */ function readLocalSkillPrompt(skillName: string): string { - // Try embedded skills first (for bundled binary) if (hasEmbeddedSkills()) { const embeddedSkill = getEmbeddedSkill(`local-${skillName}`); if (embeddedSkill) { @@ -229,7 +96,6 @@ function readLocalSkillPrompt(skillName: string): string { } } - // Fall back to reading from file system (for development) try { const skillPath = join( dirname(import.meta.url.replace('file://', '')), @@ -267,9 +133,6 @@ function appendOptionalSection( lines.push(''); } -/** - * Build pre-configured setup section from preparation context. - */ function buildPreparationSection(context: PreparationContext): string { const lines: string[] = ['## Pre-configured Setup', '']; lines.push(`Project: ${context.config.project.name}`); @@ -369,11 +232,6 @@ function buildPreparationSection(context: PreparationContext): string { return lines.join('\n'); } -/** - * Get prompt for the install skill. - * Uses the SDK integration workflow prompt. - * Prompt is loaded from src/lib/skills/install/SKILL.md - */ function getInstallPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); const context = options?.preparationContext; @@ -399,7 +257,6 @@ function getInstallPrompt(options?: SkillOptions): string { } prompt += '\n'; - // Add preparation context if available if (context) { prompt += buildPreparationSection(context); prompt += '\n'; @@ -414,11 +271,8 @@ function getInstallPrompt(options?: SkillOptions): string { 'For this run, execute SDK integration workflow. Use build commands only as verification after integration changes.\n\n'; } - // Add one-shot instruction for autonomous execution if (options?.oneShot) { prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; - - // Add explicit directive for autonomous execution prompt += `## EXECUTION MODE: AUTONOMOUS You are in autonomous one-shot execution mode: @@ -429,90 +283,43 @@ You are in autonomous one-shot execution mode: `; } - const installPrompt = readLocalSkillPrompt('install'); - prompt += installPrompt; + prompt += readLocalSkillPrompt('install'); return prompt; } -export async function getSkillPrompt( - skillType: SkillType, - options?: SkillOptions, -): Promise { - // Handle local skills - if (isLocalSkill(skillType)) { - return await getLocalSkillPrompt(skillType, options); - } - - // Get folder name from metadata - const skillFolder = getSkillFolderByCommand(skillType); - if (!skillFolder) { - throw new Error(`Unknown skill type: ${skillType}`); - } - - // Read SKILL.md from @clix-so/clix-agent-skills package - const skillMarkdown = readSkillMarkdown(skillFolder); - +function getDoctorPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); - const platform = options?.platform ?? 'auto-detect'; - - // Build prompt with context - let prompt = `Project path: ${projectPath} -Target platform: ${platform} -`; + let prompt = `Project path: ${projectPath}\n\n`; - // Add mode-specific instruction if (options?.oneShot) { - prompt += ONE_SHOT_INSTRUCTION; - } else { - prompt += INTERACTIVE_MODE_INSTRUCTION; + prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; } - prompt += `\n${skillMarkdown}`; - + prompt += readLocalSkillPrompt('doctor'); return prompt; } -/** - * Get prompt for local skills. - */ async function getLocalSkillPrompt(skillType: SkillType, options?: SkillOptions): Promise { switch (skillType) { case 'install': return getInstallPrompt(options); case 'doctor': return getDoctorPrompt(options); - case 'debug': - return getDebugPrompt({ - problemDescription: 'General debugging session', - projectPath: options?.projectPath ?? process.cwd(), - oneShot: options?.oneShot, - }); default: throw new Error(`Unknown local skill: ${skillType}`); } } -/** - * Get prompt for the doctor skill. - * Uses the doctor prompt for SDK integration status analysis. - * Prompt is loaded from src/lib/skills/doctor/SKILL.md - */ -function getDoctorPrompt(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`; +export async function getSkillPrompt( + skillType: SkillType, + options?: SkillOptions, +): Promise { + if (!isLocalSkill(skillType)) { + throw new Error(`Unknown command type: ${skillType}`); } - // Load the doctor prompt from external file - const doctorPrompt = readLocalSkillPrompt('doctor'); - prompt += doctorPrompt; - - return prompt; + return await getLocalSkillPrompt(skillType, options); } export async function* executeSkill( @@ -525,13 +332,7 @@ export async function* executeSkill( for await (const message of executor.execute(prompt, { workingDirectory: options?.projectPath, signal: options?.signal, - oneShot: options?.oneShot, })) { yield message; } } - -/** - * Re-export embedded skill metadata for use in commands. - */ -export { EMBEDDED_SKILL_METADATA, type SkillMetadata }; diff --git a/src/types/clix-agent-skills.d.ts b/src/types/clix-agent-skills.d.ts deleted file mode 100644 index e1c46c9..0000000 --- a/src/types/clix-agent-skills.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare module '@clix-so/clix-agent-skills' { - interface SkillOptions { - projectPath?: string; - platform?: 'ios' | 'android' | 'react-native' | 'flutter'; - } - - export function getIntegrationPrompt(options?: SkillOptions): string; - export function getTrackingPrompt(options?: SkillOptions): string; - export function getUserManagementPrompt(options?: SkillOptions): string; - export function getPersonalizationPrompt(options?: SkillOptions): string; -} diff --git a/src/ui/AgentExecutionUI.tsx b/src/ui/AgentExecutionUI.tsx index 2204802..6818fec 100644 --- a/src/ui/AgentExecutionUI.tsx +++ b/src/ui/AgentExecutionUI.tsx @@ -33,7 +33,6 @@ async function loadAgentConfig( const result = await selectionService.selectAgent({ mode: 'command', - allowPrompt: false, }); if (!result.agent) { diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx deleted file mode 100644 index 806eeb2..0000000 --- a/src/ui/chat/ChatApp.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import { Box, useApp } from 'ink'; -import type React from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { AgentInfo } from '../../lib/agents'; -import { getProjectConfigManager, type ProjectConfig } from '../../lib/config'; -import type { InstallationMethod, UpdateCheckResult } from '../../lib/services/update-service'; -import { AgentSelector } from '../components/AgentSelector'; -import { DebugPrompt } from '../components/DebugPrompt'; -import { InstallPreparationUI } from '../components/InstallPreparationUI'; -import { MCPInstallSelector } from '../components/MCPInstallSelector'; -import { SessionSelector } from '../components/SessionSelector'; -import { TransferSelector } from '../components/TransferSelector'; -import { UpdateNotification } from '../components/UpdateNotification'; -import { useCancelInput } from '../hooks'; -import { LoginUI } from '../LoginUI'; -import { LogoutUI } from '../LogoutUI'; -import { WhoamiUI } from '../WhoamiUI'; -import { ChatFooter } from './components/ChatFooter'; -import { ChatHeader } from './components/ChatHeader'; -import { ChatInput } from './components/ChatInput'; -import { MessageList } from './components/MessageList'; -import { ChatProvider } from './context/ChatContext'; -import { useChatActions } from './hooks/useChatActions'; -import { useCommandHandler } from './hooks/useCommandHandler'; -import { useOverlays } from './hooks/useOverlays'; - -interface UpdateInfo { - result: UpdateCheckResult; - installMethod: string; -} - -interface ChatAppInnerProps { - agent: AgentInfo; - onExit: () => void; - updateCheckPromise?: Promise | null; - disableUpdateNag?: boolean; -} - -const ChatAppInner: React.FC = ({ - agent, - onExit, - updateCheckPromise, - disableUpdateNag = false, - initialSessionId, -}) => { - const { exit } = useApp(); - const [updateInfo, setUpdateInfo] = useState(null); - const [showUpdateNotification, setShowUpdateNotification] = useState(false); - const [projectConfig, setProjectConfig] = useState(null); - - // Load project config on mount - useEffect(() => { - const loadProjectConfig = async () => { - const configManager = getProjectConfigManager(); - const config = await configManager.load(); - setProjectConfig(config); - }; - loadProjectConfig(); - }, []); - - // Handle update check result - useEffect(() => { - if (!updateCheckPromise || disableUpdateNag) return; - - updateCheckPromise.then((info) => { - if (info?.result.hasUpdate) { - setUpdateInfo(info); - setShowUpdateNotification(true); - - // Auto-hide after 60 seconds - const timer = setTimeout(() => { - setShowUpdateNotification(false); - }, 60000); - - return () => clearTimeout(timer); - } - }); - }, [updateCheckPromise, disableUpdateNag]); - - const chatActions = useChatActions({ initialSessionId }); - const { - sessionReady, - preferredAgent, - initializeAgent, - cancelRequest, - isStreaming, - currentAgent, - messages, - contextUsage, - navigateHistory, - transferSession, - addSystemMessage, - executeSkillWithResult, - } = chatActions; - - // Initialize agent after session restore (if any) - const hasInitializedRef = useRef(false); - useEffect(() => { - if (!sessionReady || hasInitializedRef.current) return; - hasInitializedRef.current = true; - initializeAgent(preferredAgent ?? agent); - }, [agent, initializeAgent, preferredAgent, sessionReady]); - - // Handle escape key or Ctrl+C to cancel streaming - useCancelInput(cancelRequest, { isActive: isStreaming }); - - // Print transfer result and exit - const handleTransferSuccess = useCallback( - (result: { sessionFile: string; command: string; agentName: string }) => { - onExit(); - console.log('\n✅ Session saved!\n'); - console.log(`File: ${result.sessionFile}\n`); - console.log(`To continue in ${result.agentName}:\n`); - console.log(result.command); - console.log(''); - exit(); - }, - [onExit, exit], - ); - - const runInstallSkill = useCallback( - (context: Parameters[1]) => { - return executeSkillWithResult('install', context, { installPhase: 'integration' }); - }, - [executeSkillWithResult], - ); - - // Overlay management - const overlays = useOverlays({ - currentAgent, - onTransferSuccess: handleTransferSuccess, - addSystemMessage, - transferSession, - switchAgent: chatActions.switchAgent, - resumeSession: chatActions.resumeSession, - cancelRequest: chatActions.cancelRequest, - executeDebugSession: chatActions.executeDebugSession, - runInstallSkill, - }); - - // Handle direct transfer command with agent name - const handleTransferWithAgent = useCallback( - async (targetAgent: string) => { - const result = await transferSession(targetAgent as 'claude' | 'codex'); - if (result.success && result.sessionFile && result.command) { - const agentDisplayName = targetAgent === 'claude' ? 'Claude Code' : 'Codex'; - handleTransferSuccess({ - sessionFile: result.sessionFile, - command: result.command, - agentName: agentDisplayName, - }); - } - }, - [transferSession, handleTransferSuccess], - ); - - // Command handler - const { handleSubmit } = useCommandHandler({ - onExit, - onTransferWithAgent: handleTransferWithAgent, - chatActions, - overlays, - }); - - // Handle exit from ChatInput (double Ctrl+C) - const handleInputExit = useCallback(() => { - onExit(); - exit(); - }, [onExit, exit]); - - // History navigation - const handleHistoryNavigate = useCallback( - (direction: 'up' | 'down'): string | null => { - return navigateHistory(direction); - }, - [navigateHistory], - ); - - const isOverlayActive = overlays.activeOverlay !== null; - - return ( - - - - {/* Update notification */} - {showUpdateNotification && - updateInfo?.result.hasUpdate && - updateInfo.result.latestVersion && ( - - )} - - {!isOverlayActive && ( - - - - )} - - {/* Overlays */} - {overlays.activeOverlay === 'agent' && ( - - )} - {overlays.activeOverlay === 'transfer' && ( - - )} - {overlays.activeOverlay === 'resume' && ( - - )} - {overlays.activeOverlay === 'mcp' && ( - - )} - {overlays.activeOverlay === 'debug' && ( - - )} - {overlays.activeOverlay === 'install-preparation' && ( - - )} - {overlays.activeOverlay === 'login' && ( - { - const expiresAt = new Date(credentials.expiresAt).toLocaleString(); - overlays.handleLoginComplete(`Login successful. Token expires at ${expiresAt}`); - }} - onError={(error) => { - overlays.handleLoginComplete(`Login failed: ${error.message}`); - }} - /> - )} - {overlays.activeOverlay === 'logout' && ( - { - overlays.handleLogoutComplete(success ? 'Successfully logged out' : 'Logout failed'); - }} - /> - )} - {overlays.activeOverlay === 'whoami' && ( - { - if (result.status === 'ok') { - overlays.handleWhoamiComplete( - `Logged in as ${result.member.name} (${result.member.email})`, - ); - } else if (result.status === 'not_logged_in') { - overlays.handleWhoamiComplete('Not logged in. Run /login to authenticate.'); - } else { - overlays.handleWhoamiComplete(`Error: ${result.message}`); - } - }} - /> - )} - - {/* Input (hidden when overlay is active) */} - {!isOverlayActive && ( - - )} - - - - ); -}; - -interface ChatAppProps { - agent: AgentInfo; - onExit: () => void; - updateCheckPromise?: Promise | null; - disableUpdateNag?: boolean; -} - -export const ChatApp: React.FC = ({ - agent, - onExit, - updateCheckPromise, - disableUpdateNag, - initialSessionId, -}) => { - return ( - - - - ); -}; diff --git a/src/ui/chat/components/AgentMessage.tsx b/src/ui/chat/components/AgentMessage.tsx deleted file mode 100644 index e70653c..0000000 --- a/src/ui/chat/components/AgentMessage.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Box } from 'ink'; -import type React from 'react'; -import { memo } from 'react'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay'; - -interface AgentMessageProps { - content: string; - status?: 'pending' | 'streaming' | 'complete' | 'error'; - agentName?: string; -} - -const AgentMessageInternal: React.FC = ({ content, status = 'complete' }) => { - const isError = status === 'error'; - - return ( - - {content && ( - - - - )} - - ); -}; - -export const AgentMessage = memo(AgentMessageInternal); diff --git a/src/ui/chat/components/ChatFooter.tsx b/src/ui/chat/components/ChatFooter.tsx deleted file mode 100644 index 2c97099..0000000 --- a/src/ui/chat/components/ChatFooter.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Box, Text } from 'ink'; -import type React from 'react'; -import { memo } from 'react'; - -interface ChatFooterProps { - isStreaming?: boolean; - contextRemaining?: number; - usedTokens?: number; - maxTokens?: number; -} - -export const ChatFooter: React.FC = memo( - ({ isStreaming = false, contextRemaining = 100, usedTokens, maxTokens }) => { - // Color based on remaining context - const contextColor = contextRemaining > 50 ? 'green' : contextRemaining > 20 ? 'yellow' : 'red'; - const formatCompact = (value: number) => { - if (value >= 1_000_000) return `${Math.round(value / 100_000) / 10}M`; - if (value >= 10_000) return `${Math.round(value / 1_000)}k`; - if (value >= 1_000) return `${Math.round(value / 100) / 10}k`; - return `${value}`; - }; - - const tokenDisplay = - typeof usedTokens === 'number' && typeof maxTokens === 'number' - ? `${formatCompact(usedTokens)}/${formatCompact(maxTokens)} tokens used` - : ''; - - return ( - - - - {isStreaming ? ( - <> - Ctrl+C - or - Esc - to cancel - - ) : ( - <> - Type - / - for commands · - Tab - to autocomplete - - )} - - - - - {contextRemaining}% - context left - - {tokenDisplay ? {tokenDisplay} : null} - - - ); - }, -); diff --git a/src/ui/chat/components/ChatHeader.tsx b/src/ui/chat/components/ChatHeader.tsx deleted file mode 100644 index bafc64c..0000000 --- a/src/ui/chat/components/ChatHeader.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { Box, Text } from 'ink'; -import type React from 'react'; -import type { AgentInfo } from '../../../lib/agents'; -import type { ProjectConfig } from '../../../lib/config'; -import { formatPath } from '../../../lib/utils/path'; -import { VERSION } from '../../../lib/version'; - -// Match Codex CLI inner width -const INNER_WIDTH = 56; - -interface ChatHeaderProps { - agent: AgentInfo | null; - isStreaming?: boolean; - directory?: string; - projectConfig?: ProjectConfig | null; -} - -export const ChatHeader: React.FC = ({ - agent, - directory = process.cwd(), - projectConfig, -}) => { - // Replace home directory with ~ for shorter display - const shortDir = formatPath(directory); - - // Truncate directory path for display (leave room for "directory: " and padding) - const maxDirLength = INNER_WIDTH - 13; - const displayDir = - shortDir.length > maxDirLength ? `${shortDir.slice(0, maxDirLength - 3)}...` : shortDir; - - const agentName = agent?.displayName ?? 'Not selected'; - const horizontalBorder = '─'.repeat(INNER_WIDTH); - - // Calculate padding for each line - const titleText = ` [>] Clix (v${VERSION})`; - const titlePadding = ' '.repeat(Math.max(0, INNER_WIDTH - titleText.length)); - - const agentPrefix = ' agent: '; - const agentSeparator = ' '; - const agentHint = '/agent to change'; - const agentText = agentPrefix + agentName + agentSeparator + agentHint; - const agentPadding = ' '.repeat(Math.max(0, INNER_WIDTH - agentText.length)); - - const dirPrefix = ' directory: '; - const dirText = dirPrefix + displayDir; - const dirPadding = ' '.repeat(Math.max(0, INNER_WIDTH - dirText.length)); - - // Project info (if configured) - const projectPrefix = ' project: '; - const projectName = projectConfig?.project.name ?? ''; - const orgName = projectConfig?.organization.name ?? ''; - const projectDisplay = projectConfig ? `${projectName} (${orgName})` : ''; - const maxProjectLength = INNER_WIDTH - projectPrefix.length - 1; - const truncatedProject = - projectDisplay.length > maxProjectLength - ? `${projectDisplay.slice(0, maxProjectLength - 3)}...` - : projectDisplay; - const projectText = projectPrefix + truncatedProject; - const projectPadding = ' '.repeat(Math.max(0, INNER_WIDTH - projectText.length)); - - // Project ID - const idPrefix = ' project_id:'; - const projectId = projectConfig?.project.id ?? ''; - const maxIdLength = INNER_WIDTH - idPrefix.length - 1; - const truncatedId = - projectId.length > maxIdLength ? `${projectId.slice(0, maxIdLength - 3)}...` : projectId; - const idText = idPrefix + truncatedId; - const idPadding = ' '.repeat(Math.max(0, INNER_WIDTH - idText.length)); - - // User info - const userPrefix = ' user: '; - const userName = projectConfig?.member.name ?? ''; - const userEmail = projectConfig?.member.email ?? ''; - const userDisplay = projectConfig ? `${userName} (${userEmail})` : ''; - const maxUserLength = INNER_WIDTH - userPrefix.length - 1; - const truncatedUser = - userDisplay.length > maxUserLength - ? `${userDisplay.slice(0, maxUserLength - 3)}...` - : userDisplay; - const userText = userPrefix + truncatedUser; - const userPadding = ' '.repeat(Math.max(0, INNER_WIDTH - userText.length)); - - const emptyContent = ' '.repeat(INNER_WIDTH); - - return ( - - {/* Top border */} - ╭{horizontalBorder}╮ - - {/* Title line */} - - │ - - - {' < '} - - {' Clix'} - {` (v${VERSION})`} - {titlePadding} - │ - - - {/* Empty line */} - - │ - {emptyContent} - │ - - - {/* Agent line */} - - │{agentPrefix} - {agentName} - {agentSeparator} - - /agent - - to change - {agentPadding} - │ - - - {/* Directory line */} - - │{dirPrefix} - {displayDir} - {dirPadding} - │ - - - {/* Project line (if configured) */} - {projectConfig && ( - - │{projectPrefix} - {truncatedProject} - {projectPadding} - │ - - )} - - {/* Project ID line (if configured) */} - {projectConfig && ( - - │{idPrefix} - {truncatedId} - {idPadding} - │ - - )} - - {/* User line (if configured) */} - {projectConfig && ( - - │{userPrefix} - {truncatedUser} - {userPadding} - │ - - )} - - {/* Bottom border */} - ╰{horizontalBorder}╯ - - ); -}; diff --git a/src/ui/chat/components/ChatInput.tsx b/src/ui/chat/components/ChatInput.tsx deleted file mode 100644 index 7a7b47d..0000000 --- a/src/ui/chat/components/ChatInput.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { Box, Text, useInput } from 'ink'; -import Spinner from 'ink-spinner'; -import TextInput from 'ink-text-input'; -import type React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isCtrlCInput } from '@/ui/hooks'; -import { getRandomPhrase } from '../constants/wittyPhrases'; -import { getFilteredCommands, SlashCommandMenu } from './SlashCommandMenu'; - -interface ChatInputProps { - onSubmit: (message: string) => void; - disabled?: boolean; - inputHistory?: string[]; - historyIndex?: number; - onHistoryNavigate?: (direction: 'up' | 'down') => string | null; - onExit?: () => void; -} - -interface InputHandlers { - handleMenuNavigation: (key: { upArrow: boolean; downArrow: boolean; tab: boolean }) => boolean; - handleHistoryNavigation: (key: { upArrow: boolean; downArrow: boolean }) => void; -} - -function createInputHandlers( - filteredCommands: Array<{ command: string; description: string }>, - selectedCommandIndex: number, - setSelectedCommandIndex: React.Dispatch>, - setValue: React.Dispatch>, - setInputKey: React.Dispatch>, - onHistoryNavigate?: (direction: 'up' | 'down') => string | null, -): InputHandlers { - const handleMenuNavigation = (key: { - upArrow: boolean; - downArrow: boolean; - tab: boolean; - }): boolean => { - if (key.upArrow) { - setSelectedCommandIndex((prev) => (prev > 0 ? prev - 1 : filteredCommands.length - 1)); - return true; - } - if (key.downArrow) { - setSelectedCommandIndex((prev) => (prev < filteredCommands.length - 1 ? prev + 1 : 0)); - return true; - } - if (key.tab) { - const selectedCmd = filteredCommands[selectedCommandIndex]; - if (selectedCmd) { - setValue(`/${selectedCmd.command} `); - setSelectedCommandIndex(0); - setInputKey((prev) => prev + 1); - } - return true; - } - return false; - }; - - const handleHistoryNavigation = (key: { upArrow: boolean; downArrow: boolean }): void => { - if (!onHistoryNavigate) return; - - if (key.upArrow) { - const historyValue = onHistoryNavigate('up'); - if (historyValue !== null) { - setValue(historyValue); - setInputKey((prev) => prev + 1); - } - } else if (key.downArrow) { - const historyValue = onHistoryNavigate('down'); - if (historyValue !== null) { - setValue(historyValue); - setInputKey((prev) => prev + 1); - } - } - }; - - return { handleMenuNavigation, handleHistoryNavigation }; -} - -// Witty phrase display for loading state -const WittyPhraseDisplay: React.FC = () => { - const [wittyPhrase, setWittyPhrase] = useState(getRandomPhrase); - - useEffect(() => { - const interval = setInterval(() => { - setWittyPhrase(getRandomPhrase()); - }, 3000); - return () => clearInterval(interval); - }, []); - - return {wittyPhrase}; -}; - -export const ChatInput: React.FC = ({ - onSubmit, - disabled = false, - onHistoryNavigate, - onExit, -}) => { - const [value, setValue] = useState(''); - const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); - const [inputKey, setInputKey] = useState(0); - const lastCtrlCTime = useRef(0); - - // Double Ctrl+C threshold (ms) - const DOUBLE_CTRL_C_THRESHOLD = 500; - - // Check if we're in slash command mode - const isSlashMode = value.startsWith('/'); - const slashFilter = isSlashMode ? value.slice(1).split(' ')[0] : ''; - const hasArgs = isSlashMode && value.includes(' '); - - // Check if we're in bash mode (! prefix) - const isBashMode = value.startsWith('!'); - - // Only show menu when typing a command (no space yet) - const showMenu = isSlashMode && !hasArgs && !disabled; - - const filteredCommands = useMemo(() => getFilteredCommands(slashFilter), [slashFilter]); - - const { handleMenuNavigation, handleHistoryNavigation } = useMemo( - () => - createInputHandlers( - filteredCommands, - selectedCommandIndex, - setSelectedCommandIndex, - setValue, - setInputKey, - onHistoryNavigate, - ), - [filteredCommands, selectedCommandIndex, onHistoryNavigate], - ); - - const clearInput = useCallback((resetInputComponent: boolean) => { - setValue(''); - setSelectedCommandIndex(0); - if (resetInputComponent) { - setInputKey((prev) => prev + 1); - } - }, []); - - useInput( - (input, key) => { - if (disabled) return; - - // Handle Ctrl+C: clear input or exit on double press - if (isCtrlCInput(input, key)) { - const now = Date.now(); - const isDoubleCtrlC = now - lastCtrlCTime.current < DOUBLE_CTRL_C_THRESHOLD; - lastCtrlCTime.current = now; - - if (isDoubleCtrlC) { - // Double Ctrl+C - exit - onExit?.(); - process.exit(130); - return; - } - - // Single Ctrl+C - clear input - if (value) { - clearInput(true); - } - return; - } - - // Handle escape: clear input (always, regardless of menu state) - if (key.escape) { - if (value) { - clearInput(true); - } - return; - } - - // Handle menu navigation when menu is visible - if (showMenu && filteredCommands.length > 0 && handleMenuNavigation(key)) return; - - // Handle history navigation (only when not in slash mode) - if (!showMenu) { - handleHistoryNavigation(key); - } - }, - { isActive: true }, - ); - - const handleChange = useCallback( - (newValue: string) => { - if (disabled) return; - setValue(newValue); - // Reset selection when filter changes - if (newValue.startsWith('/')) { - setSelectedCommandIndex(0); - } - }, - [disabled], - ); - - const handleSubmit = useCallback( - (submittedValue: string) => { - if (disabled || !submittedValue.trim()) return; - - // If in slash mode with menu visible and Enter pressed, select the command - if (showMenu && filteredCommands.length > 0) { - const selectedCmd = filteredCommands[selectedCommandIndex]; - if (selectedCmd) { - onSubmit(`/${selectedCmd.command}`); - clearInput(false); - return; - } - } - - onSubmit(submittedValue.trim()); - clearInput(false); - }, - [clearInput, disabled, onSubmit, showMenu, filteredCommands, selectedCommandIndex], - ); - - return ( - - {showMenu && ( - - )} - - {disabled ? ( - // Disabled state: show spinner and witty phrase - <> - - - - - - - ) : ( - // Active state: normal input - <> - - {isBashMode ? '! ' : '> '} - - - - )} - - - ); -}; diff --git a/src/ui/chat/components/MessageList.tsx b/src/ui/chat/components/MessageList.tsx deleted file mode 100644 index 14b06e5..0000000 --- a/src/ui/chat/components/MessageList.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type React from 'react'; -import { ChatMessageList } from '@/ui/components/ChatMessageList'; -import type { ChatMessage } from '../context/ChatContext'; - -interface MessageListProps { - messages: ChatMessage[]; - maxHeight?: number; - agentName?: string; -} - -export const MessageList: React.FC = ({ messages, maxHeight, agentName }) => { - return ; -}; diff --git a/src/ui/chat/components/SlashCommandMenu.tsx b/src/ui/chat/components/SlashCommandMenu.tsx deleted file mode 100644 index 58f38b1..0000000 --- a/src/ui/chat/components/SlashCommandMenu.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Box, Text } from 'ink'; -import type React from 'react'; - -import { getCommands } from '@/lib/commands'; -import { getAvailableSkills } from '@/lib/skills'; - -export interface SlashCommand { - command: string; - description: string; - category?: 'skill' | 'system'; -} - -function buildSkillLookup(): Map { - const skills = getAvailableSkills(); - return new Map(skills.map((s) => [s.type, { isLocal: !!s.isLocal }])); -} - -/** - * Get all slash commands dynamically. - * - * Source of truth is the command registry (`src/lib/commands/registry.ts`). - * This avoids the slash command menu falling out of sync when new commands are added. - */ -export function getSlashCommands(): SlashCommand[] { - const skillLookup = buildSkillLookup(); - const visibleCommands = getCommands().filter((c) => !c.isHidden); - - const skillCommands: SlashCommand[] = []; - const systemCommands: SlashCommand[] = []; - - for (const cmd of visibleCommands) { - const skillInfo = skillLookup.get(cmd.name); - const isSkill = !!skillInfo && !skillInfo.isLocal; - - const entry: SlashCommand = { - command: cmd.name, - description: cmd.description, - category: isSkill ? 'skill' : 'system', - }; - - if (skillInfo) { - skillCommands.push(entry); - } else { - systemCommands.push(entry); - } - } - - return [...skillCommands, ...systemCommands]; -} - -interface SlashCommandMenuProps { - filter: string; - selectedIndex: number; - visible: boolean; -} - -export const SlashCommandMenu: React.FC = ({ - filter, - selectedIndex, - visible, -}) => { - if (!visible) return null; - - const allCommands = getSlashCommands(); - const filteredCommands = allCommands.filter((cmd) => - cmd.command.toLowerCase().startsWith(filter.toLowerCase()), - ); - - if (filteredCommands.length === 0) { - return ( - - No matching commands - - ); - } - - return ( - - {filteredCommands.map((cmd, index) => { - const isSelected = index === selectedIndex; - const isSkill = cmd.category === 'skill'; - - return ( - - {isSelected ? '› ' : ' '} - - /{cmd.command} - - {cmd.description} - {isSkill && ( - - {' '} - [skill] - - )} - - ); - })} - - Tab to complete · Enter to select · Esc to cancel - - - ); -}; - -export function getFilteredCommands(filter: string): SlashCommand[] { - const allCommands = getSlashCommands(); - return allCommands.filter((cmd) => cmd.command.toLowerCase().startsWith(filter.toLowerCase())); -} diff --git a/src/ui/chat/components/UserMessage.tsx b/src/ui/chat/components/UserMessage.tsx deleted file mode 100644 index e31f0c1..0000000 --- a/src/ui/chat/components/UserMessage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Box, Text } from 'ink'; -import type React from 'react'; -import { memo } from 'react'; - -interface UserMessageProps { - content: string; -} - -const UserMessageInternal: React.FC = ({ content }) => { - return ( - - - - {'>'}{' '} - - {content} - - - ); -}; - -export const UserMessage = memo(UserMessageInternal); diff --git a/src/ui/chat/components/__tests__/slash-command-menu.test.ts b/src/ui/chat/components/__tests__/slash-command-menu.test.ts deleted file mode 100644 index 4799644..0000000 --- a/src/ui/chat/components/__tests__/slash-command-menu.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { getCommands } from '@/lib/commands'; -import { getAvailableSkills } from '@/lib/skills'; -import { getFilteredCommands, getSlashCommands } from '../SlashCommandMenu'; - -describe('SlashCommandMenu command list', () => { - test('stays in sync with the command registry (canonical names only)', () => { - const registryNames = getCommands() - .filter((c) => !c.isHidden) - .map((c) => c.name) - .sort(); - - const menuNames = getSlashCommands() - .map((c) => c.command) - .sort(); - - expect(menuNames).toEqual(registryNames); - }); - - test('filters commands by prefix', () => { - const filtered = getFilteredCommands('re').map((c) => c.command); - expect(filtered).toContain('resume'); - }); - - test('does not include aliases in menu', () => { - const menuNames = getSlashCommands().map((c) => c.command); - expect(menuNames).not.toContain('clear'); - }); - - test('marks embedded skills as skill category', () => { - const nonLocalSkill = getAvailableSkills().find((s) => !s.isLocal); - if (!nonLocalSkill) return; - - const cmd = getSlashCommands().find((c) => c.command === nonLocalSkill.type); - expect(cmd).toBeDefined(); - expect(cmd?.category).toBe('skill'); - }); - - test('marks local skills as system category', () => { - const localSkill = getAvailableSkills().find((s) => s.isLocal); - if (!localSkill) return; - - const cmd = getSlashCommands().find((c) => c.command === localSkill.type); - expect(cmd).toBeDefined(); - expect(cmd?.category).toBe('system'); - }); -}); diff --git a/src/ui/chat/constants/wittyPhrases.ts b/src/ui/chat/constants/wittyPhrases.ts deleted file mode 100644 index 01ba371..0000000 --- a/src/ui/chat/constants/wittyPhrases.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const WITTY_LOADING_PHRASES = [ - 'knocking on device doors...', - 'tapping on screens...', - 'lighting up notifications...', - 'sending smoke signals...', - 'launching message bottles...', - 'dispatching digital pigeons...', - 'releasing notification doves...', - 'warming up the push engine...', - 'revving notification motors...', - 'spinning up delivery wheels...', - 'tuning notification frequencies...', - 'conducting the push symphony...', - 'orchestrating deliveries...', - 'brewing notification potions...', - 'cooking up alerts...', - 'baking fresh messages...', - 'crafting digital postcards...', - 'building notification bridges...', - 'paving digital pathways...', - 'opening communication portals...', - 'channeling message energy...', - 'summoning engagement spirits...', - 'casting delivery spells...', - 'charging push capacitors...', - 'powering up channels...', - 'igniting notification sparks...', - 'fanning engagement flames...', - 'planting message seeds...', - 'watering notification gardens...', - 'polishing delivery pipelines...', - 'waxing the broadcast waves...', - 'stretching delivery muscles...', - 'flexing notification bandwidth...', - 'calibrating reach sensors...', - 'adjusting engagement dials...', - 'threading message needles...', - 'weaving notification tapestries...', - 'painting engagement pictures...', -]; - -let lastPhraseIndex = -1; - -export function getRandomPhrase(): string { - // Avoid repeating the same phrase twice in a row - let index: number; - do { - index = Math.floor(Math.random() * WITTY_LOADING_PHRASES.length); - } while (index === lastPhraseIndex && WITTY_LOADING_PHRASES.length > 1); - - lastPhraseIndex = index; - return WITTY_LOADING_PHRASES[index]; -} diff --git a/src/ui/chat/context/ChatContext.tsx b/src/ui/chat/context/ChatContext.tsx deleted file mode 100644 index c71019e..0000000 --- a/src/ui/chat/context/ChatContext.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { createContext, type ReactNode, useContext, useReducer } from 'react'; -import type { AgentInfo } from '../../../lib/agents'; - -export interface ChatMessage { - id: string; - role: 'user' | 'agent' | 'tool' | 'system' | 'bash'; - content: string; - timestamp: Date; - status?: 'pending' | 'streaming' | 'complete' | 'error'; - toolName?: string; - /** Bash command that was executed (for role='bash') */ - bashCommand?: string; - /** Exit code from bash command (for role='bash') */ - bashExitCode?: number | null; - /** Whether bash output was truncated (for role='bash') */ - bashTruncated?: boolean; -} - -interface ChatState { - messages: ChatMessage[]; - isStreaming: boolean; - currentAgent: AgentInfo | null; - inputHistory: string[]; - historyIndex: number; -} - -type ChatAction = - | { type: 'ADD_MESSAGE'; payload: ChatMessage } - | { type: 'UPDATE_MESSAGE'; payload: { id: string; updates: Partial } } - | { type: 'SET_STREAMING'; payload: boolean } - | { type: 'SET_AGENT'; payload: AgentInfo | null } - | { type: 'CLEAR_MESSAGES' } - | { - type: 'LOAD_PERSISTED_SESSION'; - payload: { - messages: ChatMessage[]; - inputHistory: string[]; - }; - } - | { type: 'ADD_TO_HISTORY'; payload: string } - | { type: 'SET_HISTORY_INDEX'; payload: number }; - -const initialState: ChatState = { - messages: [], - isStreaming: false, - currentAgent: null, - inputHistory: [], - historyIndex: -1, -}; - -function chatReducer(state: ChatState, action: ChatAction): ChatState { - switch (action.type) { - case 'ADD_MESSAGE': - return { - ...state, - messages: [...state.messages, action.payload], - }; - - case 'UPDATE_MESSAGE': - return { - ...state, - messages: state.messages.map((msg) => - msg.id === action.payload.id ? { ...msg, ...action.payload.updates } : msg, - ), - }; - - case 'SET_STREAMING': - return { - ...state, - isStreaming: action.payload, - }; - - case 'SET_AGENT': - return { - ...state, - currentAgent: action.payload, - }; - - case 'CLEAR_MESSAGES': - return { - ...state, - messages: [], - inputHistory: [], - historyIndex: -1, - isStreaming: false, - }; - - case 'LOAD_PERSISTED_SESSION': - return { - ...state, - messages: action.payload.messages, - inputHistory: action.payload.inputHistory, - historyIndex: -1, - isStreaming: false, - }; - - case 'ADD_TO_HISTORY': - return { - ...state, - inputHistory: [...state.inputHistory, action.payload], - historyIndex: -1, - }; - - case 'SET_HISTORY_INDEX': - return { - ...state, - historyIndex: action.payload, - }; - - default: - return state; - } -} - -interface ChatContextValue { - state: ChatState; - dispatch: React.Dispatch; -} - -const ChatContext = createContext(null); - -export function ChatProvider({ children }: { children: ReactNode }) { - const [state, dispatch] = useReducer(chatReducer, initialState); - - return {children}; -} - -export function useChatContext() { - const context = useContext(ChatContext); - if (!context) { - throw new Error('useChatContext must be used within a ChatProvider'); - } - return context; -} - -export function generateMessageId(): string { - return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; -} diff --git a/src/ui/chat/hooks/__tests__/error-handling.test.ts b/src/ui/chat/hooks/__tests__/error-handling.test.ts deleted file mode 100644 index 1fcd249..0000000 --- a/src/ui/chat/hooks/__tests__/error-handling.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Tests for error handling logic used in useMessageStreaming and related hooks. - * - * These tests verify the error message extraction logic to prevent - * "Unknown error occurred" messages when errors are thrown. - */ -import { describe, expect, test } from 'bun:test'; - -/** - * Error message extraction logic (mirrors useMessageStreaming.ts extractErrorMessage) - * Uses duck typing first to handle cross-realm Error objects in bundled binaries. - */ -function extractErrorMessage(error: unknown): string { - // Duck typing: check for message property first (handles cross-realm Errors) - if (error && typeof error === 'object' && 'message' in error) { - const msg = (error as { message: unknown }).message; - // Only use message if it's a non-empty string - if (typeof msg === 'string' && msg) { - return msg; - } - // For non-string or empty message, try other extraction methods - } - - // Standard Error check (also handles empty message via toString) - if (error instanceof Error) { - return error.message || error.toString(); - } - - // String error - if (typeof error === 'string') { - return error; - } - - // Object without message property (or with non-string/empty message) - if (error && typeof error === 'object') { - try { - return JSON.stringify(error); - } catch { - return String(error); - } - } - - // Primitives (number, boolean, symbol, bigint) - if (error !== undefined && error !== null) { - return String(error); - } - - // Unknown - add debug info - return `Unknown error (type: ${typeof error})`; -} - -describe('useMessageStreaming error handling', () => { - describe('extractErrorMessage', () => { - test('should extract message from Error instance', () => { - const error = new Error('Test error message'); - expect(extractErrorMessage(error)).toBe('Test error message'); - }); - - test('should extract message from TypeError', () => { - const error = new TypeError('Type mismatch'); - expect(extractErrorMessage(error)).toBe('Type mismatch'); - }); - - test('should extract message from RangeError', () => { - const error = new RangeError('Value out of range'); - expect(extractErrorMessage(error)).toBe('Value out of range'); - }); - - test('should handle Error with empty message', () => { - const error = new Error(''); - // Duck typing first checks message property which is empty string - // Then falls through to instanceof which uses toString() - expect(extractErrorMessage(error)).toBe('Error'); - }); - - test('should handle string errors directly', () => { - const error = 'Direct string error'; - expect(extractErrorMessage(error)).toBe('Direct string error'); - }); - - test('should handle empty string error', () => { - const error = ''; - expect(extractErrorMessage(error)).toBe(''); - }); - - test('should extract message from plain object with message property', () => { - const error = { message: 'Object error' }; - expect(extractErrorMessage(error)).toBe('Object error'); - }); - - test('should handle object with null message', () => { - const error = { message: null }; - // Falls through to JSON.stringify since message is not a non-empty string - expect(extractErrorMessage(error)).toBe('{"message":null}'); - }); - - test('should handle object with undefined message', () => { - const error = { message: undefined }; - // Falls through to JSON.stringify (undefined is omitted in JSON) - expect(extractErrorMessage(error)).toBe('{}'); - }); - - test('should JSON stringify object without message', () => { - const error = { code: 'ERR_NETWORK', status: 500 }; - expect(extractErrorMessage(error)).toBe('{"code":"ERR_NETWORK","status":500}'); - }); - - test('should handle array as error', () => { - const error = ['Error 1', 'Error 2']; - expect(extractErrorMessage(error)).toBe('["Error 1","Error 2"]'); - }); - - test('should handle number as error', () => { - const error = 500; - expect(extractErrorMessage(error)).toBe('500'); - }); - - test('should handle boolean as error', () => { - const error = false; - expect(extractErrorMessage(error)).toBe('false'); - }); - - test('should handle null as error', () => { - const error = null; - expect(extractErrorMessage(error)).toBe('Unknown error (type: object)'); - }); - - test('should handle undefined as error', () => { - const error = undefined; - expect(extractErrorMessage(error)).toBe('Unknown error (type: undefined)'); - }); - - test('should handle circular reference object', () => { - const error: Record = { name: 'circular' }; - error.self = error; - // JSON.stringify will throw, should fall back to String() - expect(extractErrorMessage(error)).toBe('[object Object]'); - }); - - test('should handle DOMException', () => { - const error = new DOMException('Operation aborted', 'AbortError'); - expect(extractErrorMessage(error)).toBe('Operation aborted'); - }); - - test('should handle custom error class', () => { - class APIError extends Error { - statusCode: number; - constructor(message: string, statusCode: number) { - super(message); - this.statusCode = statusCode; - this.name = 'APIError'; - } - } - const error = new APIError('Not found', 404); - expect(extractErrorMessage(error)).toBe('Not found'); - }); - - test('should handle object with toJSON that throws', () => { - const error = { - toJSON() { - throw new Error('toJSON failed'); - }, - }; - expect(extractErrorMessage(error)).toBe('[object Object]'); - }); - }); - - describe('real-world error scenarios', () => { - test('should handle fetch network error', () => { - // Simulates what fetch might throw - const error = new TypeError('Failed to fetch'); - expect(extractErrorMessage(error)).toBe('Failed to fetch'); - }); - - test('should handle axios-like error object', () => { - const error = { - message: 'Request failed with status code 500', - response: { status: 500, data: 'Internal Server Error' }, - }; - expect(extractErrorMessage(error)).toBe('Request failed with status code 500'); - }); - - test('should handle skills package not found error', () => { - const error = new Error( - 'Skills package not found. Please install it: npm install -g @clix-so/clix-agent-skills', - ); - expect(extractErrorMessage(error)).toContain('Skills package not found'); - expect(extractErrorMessage(error)).toContain('npm install'); - }); - - test('should handle CLI spawn error', () => { - const error = new Error('spawn claude ENOENT'); - expect(extractErrorMessage(error)).toBe('spawn claude ENOENT'); - }); - - test('should handle abort error', () => { - const error = new DOMException('The operation was aborted', 'AbortError'); - expect(extractErrorMessage(error)).toBe('The operation was aborted'); - }); - - test('should handle process exit error object', () => { - const error = { code: 1, signal: null, message: 'Process exited with code 1' }; - expect(extractErrorMessage(error)).toBe('Process exited with code 1'); - }); - - test('should handle JSON parse error', () => { - const error = new SyntaxError('Unexpected token < in JSON at position 0'); - expect(extractErrorMessage(error)).toBe('Unexpected token < in JSON at position 0'); - }); - }); - - describe('edge cases that caused "Unknown error occurred"', () => { - /** - * These test cases specifically target the scenarios that previously - * resulted in "Unknown error occurred" being displayed. - */ - - test('should NOT return "Unknown error occurred" for Error instances', () => { - const error = new Error('Specific error message'); - const message = extractErrorMessage(error); - expect(message).not.toBe('Unknown error occurred'); - expect(message).toBe('Specific error message'); - }); - - test('should NOT return "Unknown error occurred" for string errors', () => { - const error = 'String error'; - const message = extractErrorMessage(error); - expect(message).not.toBe('Unknown error occurred'); - expect(message).toBe('String error'); - }); - - test('should NOT return "Unknown error occurred" for objects with message', () => { - const error = { message: 'Error from object' }; - const message = extractErrorMessage(error); - expect(message).not.toBe('Unknown error occurred'); - expect(message).toBe('Error from object'); - }); - - test('should NOT return "Unknown error occurred" for objects without message', () => { - const error = { code: 'ERR', detail: 'Detail' }; - const message = extractErrorMessage(error); - expect(message).not.toBe('Unknown error occurred'); - expect(message).toBe('{"code":"ERR","detail":"Detail"}'); - }); - - test('returns debug info for null/undefined, string representation for primitives', () => { - // null/undefined get debug info - expect(extractErrorMessage(null)).toBe('Unknown error (type: object)'); - expect(extractErrorMessage(undefined)).toBe('Unknown error (type: undefined)'); - // primitives get string representation - expect(extractErrorMessage(123)).toBe('123'); - expect(extractErrorMessage(true)).toBe('true'); - }); - }); -}); diff --git a/src/ui/chat/hooks/__tests__/useMessageStreaming.test.ts b/src/ui/chat/hooks/__tests__/useMessageStreaming.test.ts deleted file mode 100644 index 6187ca3..0000000 --- a/src/ui/chat/hooks/__tests__/useMessageStreaming.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { mergeStreamText } from '../useMessageStreaming'; - -describe('mergeStreamText', () => { - test('appends chunks by default', () => { - expect(mergeStreamText('hello', ' world')).toBe('hello world'); - }); - - test('replaces full content when stream mode is replace', () => { - expect(mergeStreamText('hello world', 'rewritten', 'replace')).toBe('rewritten'); - }); -}); diff --git a/src/ui/chat/hooks/index.ts b/src/ui/chat/hooks/index.ts deleted file mode 100644 index 33fc6c0..0000000 --- a/src/ui/chat/hooks/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Chat hooks module. - */ - -// Types -export type { ChatRefs, ContextUsage, SlashCommandResult } from './types'; - -// Hooks -export { useAgentManagement } from './useAgentManagement'; -export { useChatActions } from './useChatActions'; -export { useCommandHandler } from './useCommandHandler'; -export { useHistoryManagement } from './useHistoryManagement'; -export { useMessageSending } from './useMessageSending'; -export { useMessageStreaming } from './useMessageStreaming'; -export type { OverlayType } from './useOverlays'; -export { useOverlays } from './useOverlays'; -export { parseSlashCommand, useSlashCommands } from './useSlashCommands'; diff --git a/src/ui/chat/hooks/types.ts b/src/ui/chat/hooks/types.ts deleted file mode 100644 index 0afbdc6..0000000 --- a/src/ui/chat/hooks/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Shared types for chat hooks. - */ -import type { MutableRefObject } from 'react'; -import type { AgentExecutor } from '../../../lib/executor'; - -/** - * Slash command parsing result. - */ -export interface SlashCommandResult { - handled: boolean; - command?: string; - args?: string[]; -} - -/** - * Context usage statistics. - */ -export interface ContextUsage { - used: number; - remaining: number; - totalChars: number; - maxChars: number; - usedTokens: number; - maxTokens: number; -} - -/** - * Shared refs between chat hooks. - */ -export interface ChatRefs { - executorRef: MutableRefObject; - abortControllerRef: MutableRefObject; - chatSessionIdRef: MutableRefObject; - agentSessionMapRef: MutableRefObject>; -} diff --git a/src/ui/chat/hooks/useAgentManagement.ts b/src/ui/chat/hooks/useAgentManagement.ts deleted file mode 100644 index afa949e..0000000 --- a/src/ui/chat/hooks/useAgentManagement.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Agent management hook for initialization, switching, and listing agents. - */ -import { useCallback } from 'react'; -import type { AgentInfo } from '../../../lib/agents'; -import { detectAvailableAgents, getAgentByName, SUPPORTED_AGENTS } from '../../../lib/agents'; -import { getConfigManager } from '../../../lib/config/index'; -import { createExecutor } from '../../../lib/executor'; -import { generateMessageId, useChatContext } from '../context/ChatContext'; -import type { ChatRefs } from './types'; -import type { SessionPersistenceAPI } from './useSessionPersistence'; - -/** - * Hook for agent management operations. - */ -export function useAgentManagement(refs: ChatRefs, session: SessionPersistenceAPI) { - const { state, dispatch } = useChatContext(); - const { executorRef, agentSessionMapRef } = refs; - const { persistSession } = session; - - const initializeAgent = useCallback( - async (agent: AgentInfo) => { - dispatch({ type: 'SET_AGENT', payload: agent }); - const executor = await createExecutor(agent); - - const restoredSessionId = agentSessionMapRef.current[agent.name] ?? null; - if (restoredSessionId) { - executor.setSessionId(restoredSessionId); - } - - executorRef.current = executor; - await persistSession(); - }, - [dispatch, executorRef, agentSessionMapRef, persistSession], - ); - - const switchAgent = useCallback( - async (agentName: string): Promise => { - const agent = getAgentByName(agentName); - if (!agent) { - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: generateMessageId(), - role: 'system', - content: `Unknown agent: ${agentName}. Use /agent to see available agents.`, - timestamp: new Date(), - }, - }); - return false; - } - - // Check if agent is available - const availableAgents = await detectAvailableAgents(); - const isAvailable = availableAgents.some((a) => a.name === agentName); - - if (!isAvailable) { - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: generateMessageId(), - role: 'system', - content: `Agent "${agent.displayName}" is not installed. Install it from: ${agent.installUrl}`, - timestamp: new Date(), - }, - }); - return false; - } - - // Persist current executor session ID before switching - const previousAgentName = state.currentAgent?.name; - if (previousAgentName && executorRef.current) { - agentSessionMapRef.current = { - ...agentSessionMapRef.current, - [previousAgentName]: executorRef.current.getSessionId(), - }; - } - - // Get current history before switching - const previousHistory = executorRef.current?.getHistory() ?? []; - - // Switch to the new agent - dispatch({ type: 'SET_AGENT', payload: agent }); - const newExecutor = await createExecutor(agent); - - // Transfer history to new agent (UI-level continuity) - if (previousHistory.length > 0) { - newExecutor.setHistory(previousHistory); - } - - // Restore per-agent CLI session ID if available - const restoredSessionId = agentSessionMapRef.current[agent.name] ?? null; - if (restoredSessionId) { - newExecutor.setSessionId(restoredSessionId); - } - - executorRef.current = newExecutor; - - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: generateMessageId(), - role: 'system', - content: `Switched to ${agent.displayName}.`, - timestamp: new Date(), - }, - }); - - // Save to config for persistence across app restarts - const configManager = getConfigManager(); - await configManager.save({ - selectedAgent: agent.name, - lastUsedAt: new Date().toISOString(), - }); - - await persistSession(); - return true; - }, - [dispatch, executorRef, agentSessionMapRef, persistSession, state.currentAgent], - ); - - const listAgents = useCallback(async () => { - const availableAgents = await detectAvailableAgents(); - const currentAgentName = state.currentAgent?.name; - - let message = 'Available agents:\n'; - availableAgents.forEach((agent) => { - const isCurrent = agent.name === currentAgentName; - const marker = isCurrent ? ' (current)' : ''; - message += ` /agent ${agent.name} - ${agent.displayName}${marker}\n`; - }); - - if (availableAgents.length === 0) { - message = 'No agents available. Install one of the following:\n'; - SUPPORTED_AGENTS.forEach((agent) => { - message += ` - ${agent.displayName}: ${agent.installUrl}\n`; - }); - message = message.trimEnd(); - } - - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: generateMessageId(), - role: 'system', - content: message.trim(), - timestamp: new Date(), - }, - }); - }, [dispatch, state.currentAgent]); - - return { - initializeAgent, - switchAgent, - listAgents, - currentAgent: state.currentAgent, - }; -} diff --git a/src/ui/chat/hooks/useBashExecution.ts b/src/ui/chat/hooks/useBashExecution.ts deleted file mode 100644 index b5a88d5..0000000 --- a/src/ui/chat/hooks/useBashExecution.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Bash execution hook for running shell commands in chat context. - */ -import { useCallback } from 'react'; -import { - executeBash, - formatBashForContext, - formatBashResult, -} from '../../../lib/services/bash-service'; -import { generateMessageId, useChatContext } from '../context/ChatContext'; -import type { ChatRefs } from './types'; -import type { SessionPersistenceAPI } from './useSessionPersistence'; - -/** - * Result of bash command parsing. - */ -export interface BashCommandResult { - /** Whether the input was handled as a bash command */ - handled: boolean; - /** The command to execute (without prefix) */ - command?: string; -} - -/** - * Parses input to check if it's a bash command. - * - * @param input - User input string - * @returns Parse result - */ -export function parseBashCommand(input: string): BashCommandResult { - if (input.startsWith('!')) { - const command = input.slice(1).trim(); - if (command) { - return { - handled: true, - command, - }; - } - } - return { handled: false }; -} - -/** - * Hook for executing bash commands in chat context. - */ -export function useBashExecution(refs: ChatRefs, session: SessionPersistenceAPI) { - const { dispatch } = useChatContext(); - const { executorRef, abortControllerRef } = refs; - const { persistSession } = session; - - const executeBashCommand = useCallback( - async (command: string) => { - // Create local abort controller to avoid race conditions - const controller = new AbortController(); - abortControllerRef.current = controller; - const { signal } = controller; - - // Add pending bash message - const messageId = generateMessageId(); - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: messageId, - role: 'bash', - content: '', - timestamp: new Date(), - status: 'pending', - bashCommand: command, - }, - }); - - // Add to input history - dispatch({ type: 'ADD_TO_HISTORY', payload: `!${command}` }); - - try { - // Execute the command - const result = await executeBash(command, { - workingDirectory: process.cwd(), - signal, - }); - - // Update message with result - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: messageId, - updates: { - content: formatBashResult(result), - status: result.success ? 'complete' : 'error', - bashExitCode: result.exitCode, - bashTruncated: result.truncated, - }, - }, - }); - - // Add to executor history for AI context - if (executorRef.current) { - const contextMessage = formatBashForContext(result); - const history = executorRef.current.getHistory(); - history.push({ role: 'user', content: contextMessage }); - executorRef.current.setHistory(history); - } - } catch (error) { - // Handle errors - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: messageId, - updates: { - content: `Error: ${errorMessage}`, - status: 'error', - bashExitCode: 1, - }, - }, - }); - } finally { - // Only clear if still our controller (avoid race condition) - if (abortControllerRef.current === controller) { - abortControllerRef.current = null; - } - await persistSession(); - } - }, - [dispatch, executorRef, abortControllerRef, persistSession], - ); - - const cancelBashCommand = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, [abortControllerRef]); - - return { - executeBashCommand, - cancelBashCommand, - }; -} diff --git a/src/ui/chat/hooks/useChatActions.ts b/src/ui/chat/hooks/useChatActions.ts deleted file mode 100644 index 6f2c04c..0000000 --- a/src/ui/chat/hooks/useChatActions.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Main chat actions hook that composes all chat functionality. - * - * This hook acts as a facade, combining: - * - useAgentManagement: Agent initialization, switching, listing - * - useMessageSending: Message sending, cancellation, skills execution - * - useHistoryManagement: History navigation, clearing, compaction, transfer - * - useSlashCommands: Slash command parsing - * - useBashExecution: Bash command execution - */ -import { useCallback, useRef } from 'react'; -import type { AgentExecutor } from '../../../lib/executor'; -import { useChatContext } from '../context/ChatContext'; -import type { ChatRefs } from './types'; -import { useAgentManagement } from './useAgentManagement'; -import { useBashExecution } from './useBashExecution'; -import { useHistoryManagement } from './useHistoryManagement'; -import { useMessageSending } from './useMessageSending'; -import { useSessionPersistence } from './useSessionPersistence'; -import { useSlashCommands } from './useSlashCommands'; - -/** - * Unified hook for all chat actions. - * - * @example - * ```tsx - * function ChatInput() { - * const { sendMessage, parseSlashCommand, navigateHistory } = useChatActions(); - * - * const handleSubmit = async (input: string) => { - * const { handled, command, args } = parseSlashCommand(input); - * if (handled) { - * // Handle slash command - * return; - * } - * await sendMessage(input); - * }; - * } - * ``` - */ -export interface ChatActionsOptions { - initialSessionId?: string; -} - -export function useChatActions(options?: ChatActionsOptions) { - const { state } = useChatContext(); - - // Shared refs between hooks - const executorRef = useRef(null); - const abortControllerRef = useRef(null); - const chatSessionIdRef = useRef(null); - const agentSessionMapRef = useRef>({}); - - const refs: ChatRefs = { executorRef, abortControllerRef, chatSessionIdRef, agentSessionMapRef }; - - const sessionPersistence = useSessionPersistence(refs, { - initialSessionId: options?.initialSessionId, - }); - - // Compose hooks - const agentManagement = useAgentManagement(refs, sessionPersistence); - const messageSending = useMessageSending(refs, sessionPersistence); - const historyManagement = useHistoryManagement(refs, sessionPersistence); - const slashCommands = useSlashCommands(); - const bashExecution = useBashExecution(refs, sessionPersistence); - - const resumeSession = useCallback( - async (sessionId: string): Promise => { - if (state.isStreaming) { - return false; - } - - await sessionPersistence.persistSession(); - const preferredAgent = await sessionPersistence.loadSession(sessionId); - - // Re-initialize agent so executor session IDs are restored. - if (preferredAgent) { - await agentManagement.initializeAgent(preferredAgent); - } else if (state.currentAgent) { - await agentManagement.initializeAgent(state.currentAgent); - } - - await sessionPersistence.persistSession(); - return true; - }, - [agentManagement, sessionPersistence, state.currentAgent, state.isStreaming], - ); - - return { - // Session persistence - sessionReady: sessionPersistence.isReady, - preferredAgent: sessionPersistence.preferredAgent, - resumeSession, - - // Agent management - initializeAgent: agentManagement.initializeAgent, - switchAgent: agentManagement.switchAgent, - listAgents: agentManagement.listAgents, - currentAgent: agentManagement.currentAgent, - - // Message sending - sendMessage: messageSending.sendMessage, - cancelRequest: messageSending.cancelRequest, - addSystemMessage: messageSending.addSystemMessage, - executeSkill: messageSending.executeSkill, - executeSkillWithResult: messageSending.executeSkillWithResult, - executeDebugSession: messageSending.executeDebugSession, - isStreaming: messageSending.isStreaming, - - // History management - clearMessages: historyManagement.clearMessages, - compactHistory: historyManagement.compactHistory, - transferSession: historyManagement.transferSession, - navigateHistory: historyManagement.navigateHistory, - contextUsage: historyManagement.contextUsage, - inputHistory: historyManagement.inputHistory, - historyIndex: historyManagement.historyIndex, - - // Slash commands - parseSlashCommand: slashCommands.parseSlashCommand, - - // Bash execution - executeBashCommand: bashExecution.executeBashCommand, - cancelBashCommand: bashExecution.cancelBashCommand, - - // State - messages: state.messages, - }; -} diff --git a/src/ui/chat/hooks/useCommandHandler.ts b/src/ui/chat/hooks/useCommandHandler.ts deleted file mode 100644 index 7cab7dc..0000000 --- a/src/ui/chat/hooks/useCommandHandler.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Command handler hook for processing slash commands. - * - * Uses the command registry pattern for extensibility and maintainability. - */ -import { useApp } from 'ink'; -import { useCallback } from 'react'; -import { generateHelpText, getCommand, getCommands } from '../../../lib/commands'; -import { getAvailableSkillTypes, type SkillType } from '../../../lib/skills'; -import { parseBashCommand } from './useBashExecution'; -import type { useChatActions } from './useChatActions'; -import type { useOverlays } from './useOverlays'; - -/** - * Handle the /update command - directs user to CLI for actual update. - */ -function handleUpdateCommand(addSystemMessage: (msg: string) => void): void { - addSystemMessage( - 'To update Clix CLI, run `clix update` from the terminal.\n\n' + - 'Available options:\n' + - ' --dry-run Preview update without executing\n' + - ' --force Skip confirmation prompt', - ); -} - -interface UseCommandHandlerOptions { - onExit: () => void; - onTransferWithAgent: (agent: string) => Promise; - chatActions: Pick< - ReturnType, - | 'sendMessage' - | 'addSystemMessage' - | 'clearMessages' - | 'compactHistory' - | 'executeSkill' - | 'executeBashCommand' - | 'parseSlashCommand' - | 'switchAgent' - | 'resumeSession' - >; - overlays: Pick< - ReturnType, - | 'showAgentSelector' - | 'showTransferSelector' - | 'showResumeSelector' - | 'showMCPInstallSelector' - | 'showDebugPrompt' - | 'showInstallPreparation' - | 'showLoginOverlay' - | 'showLogoutOverlay' - | 'showWhoamiOverlay' - >; -} - -/** - * Get skill command names that trigger skill execution. - * Dynamically generated from available skill types. - */ -function getSkillCommands(): Set { - return new Set(getAvailableSkillTypes()); -} - -/** - * Hook for handling input commands. - * - * Uses the command registry to look up commands by name or alias, - * then executes the appropriate handler based on command type. - */ -export function useCommandHandler(options: UseCommandHandlerOptions) { - const { onExit, onTransferWithAgent, chatActions, overlays } = options; - const { exit } = useApp(); - - const { - sendMessage, - addSystemMessage, - clearMessages, - compactHistory, - executeSkill, - executeBashCommand, - parseSlashCommand, - switchAgent, - resumeSession, - } = chatActions; - - const { - showAgentSelector, - showTransferSelector, - showResumeSelector, - showMCPInstallSelector, - showDebugPrompt, - showInstallPreparation, - showLoginOverlay, - showLogoutOverlay, - showWhoamiOverlay, - } = overlays; - - const handleSlashCommand = useCallback( - async (commandName: string, args: string[]) => { - const commands = getCommands(); - const command = getCommand(commandName, commands); - - if (!command) { - addSystemMessage(`Unknown command: /${commandName}`); - return; - } - - // Handle commands based on their registered name - switch (command.name) { - case 'help': - addSystemMessage(generateHelpText()); - return; - - case 'new': - await clearMessages(); - return; - - case 'compact': { - const forceArg = args[0]; - await compactHistory(forceArg === 'force'); - return; - } - - case 'transfer': { - const targetAgent = args[0]; - if (!targetAgent) { - showTransferSelector(); - } else { - await onTransferWithAgent(targetAgent); - } - return; - } - - case 'agent': { - const agentName = args[0]; - if (!agentName) { - showAgentSelector(); - } else { - await switchAgent(agentName); - } - return; - } - - case 'resume': { - const sessionId = args[0]; - if (!sessionId) { - await showResumeSelector(); - } else { - const ok = await resumeSession(sessionId); - if (!ok) { - addSystemMessage('Cannot resume while streaming.'); - } - } - return; - } - - case 'install-mcp': - showMCPInstallSelector(); - return; - - case 'debug': - showDebugPrompt(); - return; - - case 'login': - showLoginOverlay(); - return; - - case 'logout': - showLogoutOverlay(); - return; - - case 'whoami': - showWhoamiOverlay(); - return; - - case 'update': - handleUpdateCommand(addSystemMessage); - return; - - case 'exit': - onExit(); - exit(); - return; - - case 'install': - // Install requires preparation step before agent execution - showInstallPreparation(); - return; - - default: - // Handle skill commands (except install which is handled above) - if (getSkillCommands().has(command.name)) { - executeSkill(command.name as SkillType); - return; - } - - // Fallback for unhandled commands - addSystemMessage(`Command /${command.name} is not implemented yet`); - return; - } - }, - [ - addSystemMessage, - clearMessages, - compactHistory, - executeSkill, - switchAgent, - resumeSession, - showAgentSelector, - showTransferSelector, - showResumeSelector, - showMCPInstallSelector, - showDebugPrompt, - showInstallPreparation, - showLoginOverlay, - showLogoutOverlay, - showWhoamiOverlay, - onTransferWithAgent, - onExit, - exit, - ], - ); - - const handleSubmit = useCallback( - async (input: string) => { - // Check for bash command first (! prefix) - const bashResult = parseBashCommand(input); - if (bashResult.handled && bashResult.command) { - await executeBashCommand(bashResult.command); - return; - } - - // Check for slash command - const slashResult = parseSlashCommand(input); - if (slashResult.handled && slashResult.command) { - await handleSlashCommand(slashResult.command, slashResult.args ?? []); - return; - } - - await sendMessage(input); - }, - [executeBashCommand, parseSlashCommand, handleSlashCommand, sendMessage], - ); - - return { handleSubmit }; -} diff --git a/src/ui/chat/hooks/useHistoryManagement.ts b/src/ui/chat/hooks/useHistoryManagement.ts deleted file mode 100644 index 50c5f56..0000000 --- a/src/ui/chat/hooks/useHistoryManagement.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * History management hook for chat history operations. - */ -import { useCallback, useMemo } from 'react'; -import { CLAUDE_SONNET_CONTEXT_WINDOW } from '../../../lib/services/history-compaction'; -import { - type TransferAgent, - type TransferResult, - transferToAgent, -} from '../../../lib/services/transfer-service'; -import { countTokens } from '../../../lib/utils/tokenizer'; -import { generateMessageId, useChatContext } from '../context/ChatContext'; -import type { ChatRefs, ContextUsage } from './types'; -import { useMessageStreaming } from './useMessageStreaming'; -import type { SessionPersistenceAPI } from './useSessionPersistence'; - -/** - * Hook for history management operations. - */ -export function useHistoryManagement(refs: ChatRefs, session: SessionPersistenceAPI) { - const { state, dispatch } = useChatContext(); - const { executorRef } = refs; - const { addSystemMessage } = useMessageStreaming(refs); - const { persistSession, startNewSession } = session; - - const clearMessages = useCallback(async () => { - dispatch({ type: 'CLEAR_MESSAGES' }); - - // Clear executor history as well - executorRef.current?.clearHistory(); - - // Start a new persisted session file (so /new creates a new session) - await startNewSession(); - - addSystemMessage('New session started.'); - await persistSession(); - }, [dispatch, executorRef, addSystemMessage, startNewSession, persistSession]); - - const compactHistory = useCallback( - async (force?: boolean) => { - if (!executorRef.current) { - addSystemMessage('No agent configured.'); - return; - } - - addSystemMessage('Compacting conversation history...'); - - dispatch({ type: 'SET_STREAMING', payload: true }); - - try { - const result = await executorRef.current.compactHistory(force); - - if (result.compacted) { - addSystemMessage( - `History compacted: ${result.messagesCompacted} messages summarized, ${result.messagesPreserved} preserved. Size: ${result.originalLength} → ${result.newLength} chars.`, - ); - } else { - addSystemMessage('History does not need compaction yet.'); - } - } catch (error) { - addSystemMessage( - `Compaction failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } finally { - await session.persistSession(); - dispatch({ type: 'SET_STREAMING', payload: false }); - } - }, - [dispatch, executorRef, addSystemMessage, session], - ); - - const transferSession = useCallback( - async (targetAgent: TransferAgent): Promise => { - const validAgents: TransferAgent[] = ['claude', 'codex']; - if (!validAgents.includes(targetAgent)) { - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: generateMessageId(), - role: 'system', - content: `Invalid agent: ${targetAgent}. Available: ${validAgents.join(', ')}`, - timestamp: new Date(), - }, - }); - return { success: false }; - } - - const history = executorRef.current?.getHistory() ?? []; - - const result = await transferToAgent(history, { - agent: targetAgent, - workingDirectory: process.cwd(), - }); - - if (!result.success) { - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: generateMessageId(), - role: 'system', - content: `Transfer failed: ${result.error}`, - timestamp: new Date(), - }, - }); - return { success: false }; - } - - // Don't show in chat - will be printed to console on exit - return result; - }, - [dispatch, executorRef], - ); - - const navigateHistory = useCallback( - (direction: 'up' | 'down'): string | null => { - const { inputHistory, historyIndex } = state; - - if (inputHistory.length === 0) { - return null; - } - - let newIndex: number; - - if (direction === 'up') { - if (historyIndex === -1) { - newIndex = inputHistory.length - 1; - } else if (historyIndex > 0) { - newIndex = historyIndex - 1; - } else { - return null; - } - } else { - if (historyIndex === -1) { - return null; - } else if (historyIndex < inputHistory.length - 1) { - newIndex = historyIndex + 1; - } else { - dispatch({ type: 'SET_HISTORY_INDEX', payload: -1 }); - return ''; - } - } - - dispatch({ type: 'SET_HISTORY_INDEX', payload: newIndex }); - return inputHistory[newIndex] ?? null; - }, - [dispatch, state], - ); - - // Calculate context usage percentage - const contextUsage = useMemo((): ContextUsage => { - const totalChars = state.messages.reduce((acc, msg) => acc + msg.content.length, 0); - const maxTokens = CLAUDE_SONNET_CONTEXT_WINDOW; - const usedTokens = Math.min( - maxTokens, - state.messages.reduce((acc, msg) => acc + countTokens(msg.content), 0), - ); - const maxChars = maxTokens * 4; - const usedPercent = Math.min(100, Math.round((usedTokens / maxTokens) * 100)); - const remainingPercent = Math.max(0, 100 - usedPercent); - return { - used: usedPercent, - remaining: remainingPercent, - totalChars, - maxChars, - usedTokens, - maxTokens, - }; - }, [state.messages]); - - return { - clearMessages, - compactHistory, - transferSession, - navigateHistory, - contextUsage, - inputHistory: state.inputHistory, - historyIndex: state.historyIndex, - }; -} diff --git a/src/ui/chat/hooks/useMessageSending.ts b/src/ui/chat/hooks/useMessageSending.ts deleted file mode 100644 index 399b3c3..0000000 --- a/src/ui/chat/hooks/useMessageSending.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Message sending hook for user message submission. - */ -import { useCallback } from 'react'; -import type { PreparationContext } from '../../../commands/skill/preparation'; -import { getDebugPrompt } from '../../../lib/services/debug-service'; -import { - executeSkill as executeSkillLib, - getSkillInfo, - type SkillOptions, - type SkillType, -} from '../../../lib/skills'; -import { generateMessageId, useChatContext } from '../context/ChatContext'; -import type { ChatRefs } from './types'; -import { useMessageStreaming } from './useMessageStreaming'; -import type { SessionPersistenceAPI } from './useSessionPersistence'; - -export interface SkillExecutionResult { - success: boolean; - aborted: boolean; - error?: string; -} - -export interface SkillExecutionOptions { - installPhase?: SkillOptions['installPhase']; -} - -/** - * Hook for message sending operations. - */ -export function useMessageSending(refs: ChatRefs, session: SessionPersistenceAPI) { - const { state, dispatch } = useChatContext(); - const { executorRef, abortControllerRef } = refs; - const { persistSession } = session; - const { addSystemMessage, processStreamingMessages, createAgentMessage, handleStreamingError } = - useMessageStreaming(refs); - - const cancelRequest = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, [abortControllerRef]); - - const sendMessage = useCallback( - async (content: string) => { - if (!executorRef.current) { - addSystemMessage('No agent configured. Please run "clix config" to select an agent.'); - return; - } - - // Add user message - const userMessageId = generateMessageId(); - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: userMessageId, - role: 'user', - content, - timestamp: new Date(), - }, - }); - - // Add to input history - dispatch({ type: 'ADD_TO_HISTORY', payload: content }); - - // Create agent message placeholder - const agentMessageId = createAgentMessage(); - - dispatch({ type: 'SET_STREAMING', payload: true }); - - // Create new AbortController for this request - abortControllerRef.current = new AbortController(); - const { signal } = abortControllerRef.current; - - try { - const messageGenerator = executorRef.current.execute(content, { - workingDirectory: process.cwd(), - signal, - }); - - await processStreamingMessages(messageGenerator, agentMessageId, { signal }); - } catch (error) { - // Handle abort error gracefully - if (error instanceof Error && error.name === 'AbortError') { - addSystemMessage('Request interrupted.'); - } else { - handleStreamingError(error, agentMessageId); - } - } finally { - abortControllerRef.current = null; - await persistSession(); - dispatch({ type: 'SET_STREAMING', payload: false }); - } - }, - [ - dispatch, - executorRef, - abortControllerRef, - addSystemMessage, - processStreamingMessages, - createAgentMessage, - handleStreamingError, - persistSession, - ], - ); - - const executeSkillWithResult = useCallback( - async ( - skillType: SkillType, - preparationContext?: PreparationContext, - options?: SkillExecutionOptions, - ): Promise => { - if (!executorRef.current) { - const error = 'No agent configured. Please run "clix config" to select an agent.'; - addSystemMessage(error); - return { success: false, aborted: false, error }; - } - - const skillInfo = getSkillInfo(skillType); - if (!skillInfo) { - const error = `Unknown skill: ${skillType}`; - addSystemMessage(error); - return { success: false, aborted: false, error }; - } - - // Add system message showing skill activation - addSystemMessage(`Starting skill: ${skillInfo.name} - ${skillInfo.description}`); - - // Create agent message placeholder - const agentMessageId = createAgentMessage(); - - dispatch({ type: 'SET_STREAMING', payload: true }); - - // Create new AbortController for this request - abortControllerRef.current = new AbortController(); - const { signal } = abortControllerRef.current; - - try { - const messageGenerator = executeSkillLib(skillType, executorRef.current, { - projectPath: process.cwd(), - signal, - oneShot: false, // Chat mode: enable session persistence - preparationContext, - installPhase: options?.installPhase, - }); - - const streamResult = await processStreamingMessages(messageGenerator, agentMessageId, { - signal, - }); - - if (streamResult.aborted || signal.aborted) { - addSystemMessage('Request interrupted.'); - return { - success: false, - aborted: true, - error: 'Request interrupted.', - }; - } - - if (streamResult.errorMessage) { - return { - success: false, - aborted: false, - error: streamResult.errorMessage, - }; - } - - return { - success: true, - aborted: false, - }; - } catch (error) { - // Handle abort error gracefully - if (error instanceof Error && error.name === 'AbortError') { - addSystemMessage('Request interrupted.'); - return { - success: false, - aborted: true, - error: 'Request interrupted.', - }; - } else { - handleStreamingError(error, agentMessageId); - return { - success: false, - aborted: false, - error: error instanceof Error ? error.message : 'Unknown execution error', - }; - } - } finally { - abortControllerRef.current = null; - await persistSession(); - dispatch({ type: 'SET_STREAMING', payload: false }); - } - }, - [ - dispatch, - executorRef, - abortControllerRef, - addSystemMessage, - processStreamingMessages, - createAgentMessage, - handleStreamingError, - persistSession, - ], - ); - - const executeSkill = useCallback( - async ( - skillType: SkillType, - preparationContext?: PreparationContext, - options?: SkillExecutionOptions, - ): Promise => { - await executeSkillWithResult(skillType, preparationContext, options); - }, - [executeSkillWithResult], - ); - - const executeDebugSession = useCallback( - async (problemDescription: string) => { - if (!executorRef.current) { - addSystemMessage('No agent configured. Please run "clix config" to select an agent.'); - return; - } - - // Show problem description - addSystemMessage(`Starting debug session for: "${problemDescription}"`); - - // Get debug prompt - const debugPrompt = getDebugPrompt({ - problemDescription, - projectPath: process.cwd(), - }); - - // Create agent message placeholder - const agentMessageId = createAgentMessage(); - - dispatch({ type: 'SET_STREAMING', payload: true }); - - // Create new AbortController for this request - abortControllerRef.current = new AbortController(); - const { signal } = abortControllerRef.current; - - try { - const messageGenerator = executorRef.current.execute(debugPrompt, { - workingDirectory: process.cwd(), - signal, - }); - - await processStreamingMessages(messageGenerator, agentMessageId, { signal }); - } catch (error) { - // Handle abort error gracefully - if (error instanceof Error && error.name === 'AbortError') { - addSystemMessage('Request interrupted.'); - } else { - handleStreamingError(error, agentMessageId); - } - } finally { - abortControllerRef.current = null; - await persistSession(); - dispatch({ type: 'SET_STREAMING', payload: false }); - } - }, - [ - dispatch, - executorRef, - abortControllerRef, - addSystemMessage, - processStreamingMessages, - createAgentMessage, - handleStreamingError, - persistSession, - ], - ); - return { - sendMessage, - cancelRequest, - addSystemMessage, - executeSkill, - executeSkillWithResult, - executeDebugSession, - isStreaming: state.isStreaming, - }; -} diff --git a/src/ui/chat/hooks/useMessageStreaming.ts b/src/ui/chat/hooks/useMessageStreaming.ts deleted file mode 100644 index 6c6bc4e..0000000 --- a/src/ui/chat/hooks/useMessageStreaming.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Message streaming hook for handling streamed agent responses. - */ -import { useCallback } from 'react'; -import type { AgentMessage, AgentTextStreamMode } from '../../../lib/executor'; -import { normalizeStreamText } from '../../../lib/utils/stream-text'; -import { generateMessageId, useChatContext } from '../context/ChatContext'; -import type { ChatRefs } from './types'; - -export interface StreamingOptions { - signal?: AbortSignal; -} - -export interface StreamingProcessResult { - aborted: boolean; - errorMessage: string | null; -} - -type ChatDispatch = ReturnType['dispatch']; - -export function mergeStreamText( - current: string, - chunk: string, - streamMode: AgentTextStreamMode = 'append', -): string { - if (streamMode === 'replace') { - return chunk; - } - return `${current}${chunk}`; -} - -function getInterruptedContent(content: string): string { - const interruptedSuffix = content.endsWith('\n') ? '[Interrupted]' : '\n\n[Interrupted]'; - return `${content}${interruptedSuffix}`; -} - -function markPendingToolsComplete(dispatch: ChatDispatch, pendingToolIds: string[]): void { - for (const toolId of pendingToolIds) { - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: toolId, - updates: { status: 'complete' }, - }, - }); - } -} - -function processStreamMessage(params: { - message: AgentMessage; - dispatch: ChatDispatch; - agentMessageId: string; - accumulatedContent: string; - pendingToolIds: string[]; -}): string { - const { message, dispatch, agentMessageId, accumulatedContent, pendingToolIds } = params; - - switch (message.type) { - case 'text': { - const nextContent = mergeStreamText( - accumulatedContent, - normalizeStreamText(message.content), - message.streamMode, - ); - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { content: nextContent, status: 'streaming' }, - }, - }); - return nextContent; - } - - case 'tool_call': { - const toolMessageId = generateMessageId(); - pendingToolIds.push(toolMessageId); - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: toolMessageId, - role: 'tool', - content: message.content, - toolName: (message.metadata?.toolName as string) ?? 'Tool', - timestamp: new Date(), - status: 'pending', - }, - }); - return accumulatedContent; - } - - case 'tool_result': { - const toolId = pendingToolIds.shift(); - if (toolId) { - const normalizedResult = message.content.replace(/\s+/g, ' ').trim(); - const truncatedResult = - normalizedResult.length > 400 ? `${normalizedResult.slice(0, 397)}...` : normalizedResult; - const updates: { status: 'complete'; content?: string } = { status: 'complete' }; - if (truncatedResult) { - updates.content = truncatedResult; - } - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: toolId, - updates, - }, - }); - } - return accumulatedContent; - } - - case 'error': - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { content: message.content, status: 'error' }, - }, - }); - return accumulatedContent; - - case 'complete': - return accumulatedContent; - } -} - -/** - * Extract error message from various error types. - * Uses duck typing first to handle cross-realm Error objects in bundled binaries. - */ -function extractErrorMessage(error: unknown): string { - // Duck typing: check for message property first (handles cross-realm Errors) - if (error && typeof error === 'object' && 'message' in error) { - const msg = (error as { message: unknown }).message; - // Only use message if it's a non-empty string - if (typeof msg === 'string' && msg) { - return msg; - } - // For non-string or empty message, try other extraction methods - } - - // Standard Error check (also handles empty message via toString) - if (error instanceof Error) { - return error.message || error.toString(); - } - - // String error - if (typeof error === 'string') { - return error; - } - - // Object without message property (or with non-string/empty message) - if (error && typeof error === 'object') { - try { - return JSON.stringify(error); - } catch { - return String(error); - } - } - - // Primitives (number, boolean, symbol, bigint) - if (error !== undefined && error !== null) { - return String(error); - } - - // Unknown - add debug info - return `Unknown error (type: ${typeof error})`; -} - -/** - * Hook for handling message streaming from agent. - */ -export function useMessageStreaming(_refs: ChatRefs) { - const { state, dispatch } = useChatContext(); - - const addSystemMessage = useCallback( - (content: string) => { - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: generateMessageId(), - role: 'system', - content, - timestamp: new Date(), - }, - }); - }, - [dispatch], - ); - - /** - * Process streaming messages from an async generator. - */ - const processStreamingMessages = useCallback( - async ( - messageGenerator: AsyncGenerator, - agentMessageId: string, - options: StreamingOptions = {}, - ): Promise => { - const { signal } = options; - let accumulatedContent = ''; - let aborted = false; - let errorMessage: string | null = null; - // Track pending tool message IDs locally to avoid stale closure issues - const pendingToolIds: string[] = []; - - for await (const message of messageGenerator) { - if (signal?.aborted) { - aborted = true; - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { - content: getInterruptedContent(accumulatedContent), - status: 'complete', - }, - }, - }); - markPendingToolsComplete(dispatch, pendingToolIds); - break; - } - - if (message.type === 'error') { - errorMessage = message.content; - } - - accumulatedContent = processStreamMessage({ - message, - dispatch, - agentMessageId, - accumulatedContent, - pendingToolIds, - }); - } - - // Ensure the message is marked complete (if not aborted and no error surfaced) - if (!signal?.aborted && !errorMessage) { - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { status: 'complete' }, - }, - }); - } - - if (!aborted) { - markPendingToolsComplete(dispatch, pendingToolIds); - } - - return { - aborted, - errorMessage, - }; - }, - [dispatch], - ); - - /** - * Create agent message placeholder and start streaming. - */ - const createAgentMessage = useCallback((): string => { - const agentMessageId = generateMessageId(); - dispatch({ - type: 'ADD_MESSAGE', - payload: { - id: agentMessageId, - role: 'agent', - content: '', - timestamp: new Date(), - status: 'streaming', - }, - }); - return agentMessageId; - }, [dispatch]); - - /** - * Handle streaming error. - */ - const handleStreamingError = useCallback( - (error: unknown, agentMessageId: string) => { - const errorMessage = extractErrorMessage(error); - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { - content: errorMessage, - status: 'error', - }, - }, - }); - }, - [dispatch], - ); - - return { - addSystemMessage, - processStreamingMessages, - createAgentMessage, - handleStreamingError, - messages: state.messages, - }; -} diff --git a/src/ui/chat/hooks/useOverlays.ts b/src/ui/chat/hooks/useOverlays.ts deleted file mode 100644 index f342da3..0000000 --- a/src/ui/chat/hooks/useOverlays.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Overlay state management hook for modal-like UI elements. - */ -import { useCallback, useEffect, useState } from 'react'; -import { - type ChatSessionSummary, - listChatSessions, - ONE_WEEK_MS, -} from '@/lib/services/session-store'; -import type { PreparationContext } from '../../../commands/skill/preparation'; -import type { AgentInfo } from '../../../lib/agents'; -import { detectAvailableAgents } from '../../../lib/agents'; -import { - getMCPAgentConfigs, - getMCPAgentDisplayName, - installMCPServer, - type MCPTargetAgent, -} from '../../../lib/services/mcp-install-service'; -import type { useChatActions } from './useChatActions'; - -export type OverlayType = - | 'agent' - | 'transfer' - | 'resume' - | 'mcp' - | 'debug' - | 'install-preparation' - | 'login' - | 'logout' - | 'whoami' - | null; - -interface UseOverlaysOptions { - currentAgent: AgentInfo | null; - onTransferSuccess: (result: { sessionFile: string; command: string; agentName: string }) => void; - addSystemMessage: (content: string) => void; - transferSession: ReturnType['transferSession']; - switchAgent: ReturnType['switchAgent']; - resumeSession: ReturnType['resumeSession']; - cancelRequest: ReturnType['cancelRequest']; - executeDebugSession: ReturnType['executeDebugSession']; - runInstallSkill: (context: PreparationContext) => Promise<{ - success: boolean; - aborted?: boolean; - error?: string; - }>; -} - -/** - * Hook for managing overlay/modal UI state. - */ -export function useOverlays(options: UseOverlaysOptions) { - const { - currentAgent, - onTransferSuccess, - addSystemMessage, - transferSession, - switchAgent, - resumeSession, - cancelRequest, - executeDebugSession, - runInstallSkill, - } = options; - - // Active overlay - const [activeOverlay, setActiveOverlay] = useState(null); - - // Agent selector state - const [availableAgents, setAvailableAgents] = useState([]); - const [agentSelectorIndex, setAgentSelectorIndex] = useState(0); - - // Transfer selector state - const [transferAgents, setTransferAgents] = useState([]); - const [transferSelectorIndex, setTransferSelectorIndex] = useState(0); - - // Resume selector state - const [resumeSessions, setResumeSessions] = useState([]); - const [resumeSelectorIndex, setResumeSelectorIndex] = useState(0); - - // MCP install selector state - const [mcpSelectorIndex, setMCPSelectorIndex] = useState(0); - - // Load available agents when selector is shown - useEffect(() => { - if (activeOverlay === 'agent') { - detectAvailableAgents().then((agents) => { - setAvailableAgents(agents); - const currentIndex = agents.findIndex((a) => a.name === currentAgent?.name); - setAgentSelectorIndex(currentIndex >= 0 ? currentIndex : 0); - }); - } - }, [activeOverlay, currentAgent]); - - // Load transfer agents when transfer selector is shown - useEffect(() => { - if (activeOverlay === 'transfer') { - detectAvailableAgents().then((agents) => { - const transferable = agents.filter((a) => a.name === 'claude' || a.name === 'codex'); - setTransferAgents(transferable); - setTransferSelectorIndex(0); - }); - } - }, [activeOverlay]); - - // Overlay openers - const showAgentSelector = useCallback(() => setActiveOverlay('agent'), []); - const showTransferSelector = useCallback(() => setActiveOverlay('transfer'), []); - - const showResumeSelector = useCallback(async () => { - const sessions = await listChatSessions(ONE_WEEK_MS); - if (sessions.length === 0) { - addSystemMessage('No saved sessions found (last 7 days).'); - return; - } - setResumeSessions(sessions); - setResumeSelectorIndex(0); - setActiveOverlay('resume'); - }, [addSystemMessage]); - - const showMCPInstallSelector = useCallback(() => { - setActiveOverlay('mcp'); - setMCPSelectorIndex(0); - }, []); - - const showDebugPrompt = useCallback(() => setActiveOverlay('debug'), []); - const showInstallPreparation = useCallback(() => setActiveOverlay('install-preparation'), []); - const showLoginOverlay = useCallback(() => setActiveOverlay('login'), []); - const showLogoutOverlay = useCallback(() => setActiveOverlay('logout'), []); - const showWhoamiOverlay = useCallback(() => setActiveOverlay('whoami'), []); - const hideOverlay = useCallback(() => setActiveOverlay(null), []); - - // Agent selector handlers - const handleAgentSelect = useCallback( - async (selectedAgent: AgentInfo) => { - setActiveOverlay(null); - if (selectedAgent.name !== currentAgent?.name) { - await switchAgent(selectedAgent.name); - } - }, - [switchAgent, currentAgent], - ); - - const handleAgentSelectorNavigate = useCallback( - (direction: 'up' | 'down') => { - setAgentSelectorIndex((prev) => { - if (direction === 'up') { - return prev > 0 ? prev - 1 : availableAgents.length - 1; - } else { - return prev < availableAgents.length - 1 ? prev + 1 : 0; - } - }); - }, - [availableAgents.length], - ); - - // Transfer selector handlers - const handleTransferSelect = useCallback( - async (selectedAgent: AgentInfo) => { - setActiveOverlay(null); - const result = await transferSession(selectedAgent.name as 'claude' | 'codex'); - if (result.success && result.sessionFile && result.command) { - onTransferSuccess({ - sessionFile: result.sessionFile, - command: result.command, - agentName: selectedAgent.displayName, - }); - } - }, - [transferSession, onTransferSuccess], - ); - - const handleTransferSelectorNavigate = useCallback( - (direction: 'up' | 'down') => { - setTransferSelectorIndex((prev) => { - if (direction === 'up') { - return prev > 0 ? prev - 1 : transferAgents.length - 1; - } else { - return prev < transferAgents.length - 1 ? prev + 1 : 0; - } - }); - }, - [transferAgents.length], - ); - - // Resume selector handlers - const handleResumeSelect = useCallback( - async (session: ChatSessionSummary) => { - setActiveOverlay(null); - const ok = await resumeSession(session.id); - if (!ok) { - addSystemMessage('Cannot resume while streaming.'); - } - }, - [addSystemMessage, resumeSession], - ); - - const handleResumeSelectorNavigate = useCallback( - (direction: 'up' | 'down') => { - setResumeSelectorIndex((prev) => { - if (direction === 'up') { - return prev > 0 ? prev - 1 : resumeSessions.length - 1; - } else { - return prev < resumeSessions.length - 1 ? prev + 1 : 0; - } - }); - }, - [resumeSessions.length], - ); - - // MCP install handlers - const handleMCPInstallSelect = useCallback( - async (agent: MCPTargetAgent) => { - setActiveOverlay(null); - addSystemMessage(`Installing Clix MCP Server for ${getMCPAgentDisplayName(agent)}...`); - - const result = await installMCPServer(agent); - - if (result.success) { - addSystemMessage(result.message); - } else { - addSystemMessage(`Error: ${result.message}${result.error ? `\n${result.error}` : ''}`); - } - }, - [addSystemMessage], - ); - - const handleMCPSelectorNavigate = useCallback((direction: 'up' | 'down') => { - const mcpAgents = getMCPAgentConfigs(); - setMCPSelectorIndex((prev) => { - if (direction === 'up') { - return prev > 0 ? prev - 1 : mcpAgents.length - 1; - } else { - return prev < mcpAgents.length - 1 ? prev + 1 : 0; - } - }); - }, []); - - // Debug prompt handlers - const handleDebugPromptSubmit = useCallback( - async (description: string) => { - setActiveOverlay(null); - await executeDebugSession(description); - }, - [executeDebugSession], - ); - - // Install preparation handlers - const handleInstallPreparationComplete = useCallback((_context: PreparationContext) => { - setActiveOverlay(null); - }, []); - - const handleInstallSkill = useCallback( - async (context: PreparationContext) => { - return await runInstallSkill(context); - }, - [runInstallSkill], - ); - - const handleInstallPreparationCancel = useCallback(() => { - cancelRequest(); - setActiveOverlay(null); - addSystemMessage('Install preparation cancelled'); - }, [addSystemMessage, cancelRequest]); - - // Login handlers - const handleLoginComplete = useCallback( - (message: string) => { - setActiveOverlay(null); - addSystemMessage(message); - }, - [addSystemMessage], - ); - - // Logout handlers - const handleLogoutComplete = useCallback( - (message: string) => { - setActiveOverlay(null); - addSystemMessage(message); - }, - [addSystemMessage], - ); - - // Whoami handlers - const handleWhoamiComplete = useCallback( - (message: string) => { - setActiveOverlay(null); - addSystemMessage(message); - }, - [addSystemMessage], - ); - - return { - activeOverlay, - hideOverlay, - - // Agent selector - showAgentSelector, - availableAgents, - agentSelectorIndex, - handleAgentSelect, - handleAgentSelectorNavigate, - - // Transfer selector - showTransferSelector, - transferAgents, - transferSelectorIndex, - handleTransferSelect, - handleTransferSelectorNavigate, - - // Resume selector - showResumeSelector, - resumeSessions, - resumeSelectorIndex, - handleResumeSelect, - handleResumeSelectorNavigate, - - // MCP install selector - showMCPInstallSelector, - mcpSelectorIndex, - handleMCPInstallSelect, - handleMCPSelectorNavigate, - - // Debug prompt - showDebugPrompt, - handleDebugPromptSubmit, - - // Install preparation - showInstallPreparation, - handleInstallSkill, - handleInstallPreparationComplete, - handleInstallPreparationCancel, - - // Auth overlays - showLoginOverlay, - handleLoginComplete, - showLogoutOverlay, - handleLogoutComplete, - showWhoamiOverlay, - handleWhoamiComplete, - }; -} diff --git a/src/ui/chat/hooks/useSessionPersistence.ts b/src/ui/chat/hooks/useSessionPersistence.ts deleted file mode 100644 index 95cdc8c..0000000 --- a/src/ui/chat/hooks/useSessionPersistence.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import type { AgentInfo } from '@/lib/agents'; -import { getAgentByName } from '@/lib/agents'; -import { - CHAT_SESSION_SCHEMA_VERSION, - cleanupOldChatSessions, - createChatSessionId, - deserializeChatMessages, - loadChatSession, - ONE_WEEK_MS, - type PersistedChatSession, - saveChatSession, - serializeChatMessages, -} from '@/lib/services/session-store'; -import { useChatContext } from '../context/ChatContext'; -import type { ChatRefs } from './types'; - -export interface SessionPersistenceAPI { - isReady: boolean; - preferredAgent: AgentInfo | null; - persistSession(): Promise; - startNewSession(): Promise; - loadSession(sessionId: string): Promise; -} - -export interface SessionPersistenceOptions { - initialSessionId?: string; -} - -export function useSessionPersistence( - refs: ChatRefs, - options?: SessionPersistenceOptions, -): SessionPersistenceAPI { - const { state, dispatch } = useChatContext(); - const { executorRef, chatSessionIdRef, agentSessionMapRef } = refs; - - const createdAtRef = useRef(Date.now()); - const stateRef = useRef(state); - - const [isReady, setIsReady] = useState(false); - const [preferredAgent, setPreferredAgent] = useState(null); - - useEffect(() => { - stateRef.current = state; - }, [state]); - - const persistSession = useCallback(async () => { - const sessionId = chatSessionIdRef.current; - if (!sessionId) return; - - const currentState = stateRef.current; - const agentName = currentState.currentAgent?.name ?? null; - - // Keep per-agent session IDs up-to-date. - if (agentName && executorRef.current) { - agentSessionMapRef.current = { - ...agentSessionMapRef.current, - [agentName]: executorRef.current.getSessionId(), - }; - } - - const session: PersistedChatSession = { - version: CHAT_SESSION_SCHEMA_VERSION, - id: sessionId, - createdAt: createdAtRef.current, - updatedAt: Date.now(), - currentAgentName: agentName, - messages: serializeChatMessages(currentState.messages), - inputHistory: currentState.inputHistory, - agentSessions: agentSessionMapRef.current, - }; - - await saveChatSession(session); - }, [agentSessionMapRef, chatSessionIdRef, executorRef]); - - const startNewSession = useCallback(async () => { - await cleanupOldChatSessions(ONE_WEEK_MS); - - const newId = createChatSessionId(); - chatSessionIdRef.current = newId; - agentSessionMapRef.current = {}; - createdAtRef.current = Date.now(); - - const empty: PersistedChatSession = { - version: CHAT_SESSION_SCHEMA_VERSION, - id: newId, - createdAt: createdAtRef.current, - updatedAt: Date.now(), - currentAgentName: null, - messages: [], - inputHistory: [], - agentSessions: {}, - }; - - await saveChatSession(empty); - }, [agentSessionMapRef, chatSessionIdRef]); - - const applySession = useCallback( - (session: PersistedChatSession) => { - chatSessionIdRef.current = session.id; - agentSessionMapRef.current = session.agentSessions ?? {}; - createdAtRef.current = session.createdAt; - - dispatch({ - type: 'LOAD_PERSISTED_SESSION', - payload: { - messages: deserializeChatMessages(session.messages), - inputHistory: session.inputHistory, - }, - }); - - const agent = session.currentAgentName ? getAgentByName(session.currentAgentName) : null; - setPreferredAgent(agent ?? null); - return agent ?? null; - }, - [agentSessionMapRef, chatSessionIdRef, dispatch], - ); - - const loadSession = useCallback( - async (sessionId: string): Promise => { - await cleanupOldChatSessions(ONE_WEEK_MS); - - const session = await loadChatSession(sessionId); - if (!session) return null; - if (Date.now() - session.updatedAt > ONE_WEEK_MS) return null; - - return applySession(session); - }, - [applySession], - ); - - useEffect(() => { - let cancelled = false; - - const initialize = async () => { - await cleanupOldChatSessions(ONE_WEEK_MS); - - const initialSessionId = options?.initialSessionId; - if (initialSessionId) { - const loaded = await loadChatSession(initialSessionId); - if (loaded && Date.now() - loaded.updatedAt <= ONE_WEEK_MS) { - if (!cancelled) { - applySession(loaded); - setIsReady(true); - } - return; - } - } - - // Always start a fresh session on app launch. - const newId = createChatSessionId(); - chatSessionIdRef.current = newId; - agentSessionMapRef.current = {}; - createdAtRef.current = Date.now(); - - await saveChatSession({ - version: CHAT_SESSION_SCHEMA_VERSION, - id: newId, - createdAt: createdAtRef.current, - updatedAt: Date.now(), - currentAgentName: null, - messages: [], - inputHistory: [], - agentSessions: {}, - }); - - if (!cancelled) { - setIsReady(true); - } - }; - - void initialize(); - - return () => { - cancelled = true; - }; - }, [applySession, options?.initialSessionId, agentSessionMapRef, chatSessionIdRef]); - - useEffect(() => { - return () => { - void persistSession(); - }; - }, [persistSession]); - - return { - isReady, - preferredAgent, - persistSession, - startNewSession, - loadSession, - }; -} diff --git a/src/ui/chat/hooks/useSlashCommands.ts b/src/ui/chat/hooks/useSlashCommands.ts deleted file mode 100644 index c593f2d..0000000 --- a/src/ui/chat/hooks/useSlashCommands.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Slash command parsing hook. - */ -import { useCallback } from 'react'; -import type { SlashCommandResult } from './types'; - -/** - * Pure function to parse slash commands. - */ -export function parseSlashCommand(input: string): SlashCommandResult { - if (!input.startsWith('/')) { - return { handled: false }; - } - - const parts = input.slice(1).split(/\s+/); - const command = parts[0]?.toLowerCase(); - const args = parts.slice(1); - - return { handled: true, command, args }; -} - -/** - * Hook for slash command parsing. - */ -export function useSlashCommands() { - const parse = useCallback((input: string): SlashCommandResult => { - return parseSlashCommand(input); - }, []); - - return { parseSlashCommand: parse }; -} diff --git a/src/ui/chat/services/__tests__/session-store.test.ts b/src/ui/chat/services/__tests__/session-store.test.ts deleted file mode 100644 index ba11d68..0000000 --- a/src/ui/chat/services/__tests__/session-store.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - -import { - CHAT_SESSION_SCHEMA_VERSION, - cleanupOldChatSessions, - loadChatSession, - loadLatestChatSession, - ONE_WEEK_MS, - type PersistedChatSession, - saveChatSession, -} from '@/lib/services/session-store'; - -describe('chat session persistence', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'clix-sessions-')); - process.env.CLIX_SESSION_DIR = tmpDir; - }); - - afterEach(async () => { - process.env.CLIX_SESSION_DIR = undefined; - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - test('keeps sessions within one week and deletes older', async () => { - const now = Date.now(); - - const oldSession: PersistedChatSession = { - version: CHAT_SESSION_SCHEMA_VERSION, - id: 'old', - createdAt: now - ONE_WEEK_MS * 2, - updatedAt: now - ONE_WEEK_MS * 2, - currentAgentName: null, - messages: [], - inputHistory: [], - agentSessions: {}, - }; - - const recentSession: PersistedChatSession = { - version: CHAT_SESSION_SCHEMA_VERSION, - id: 'recent', - createdAt: now - 60_000, - updatedAt: now - 60_000, - currentAgentName: null, - messages: [], - inputHistory: [], - agentSessions: {}, - }; - - await saveChatSession(oldSession); - await saveChatSession(recentSession); - - const result = await cleanupOldChatSessions(ONE_WEEK_MS); - expect(result.deleted).toBe(1); - - expect(await loadChatSession('old')).toBeNull(); - expect(await loadChatSession('recent')).not.toBeNull(); - }); - - test('loads the most recently updated session', async () => { - const now = Date.now(); - - await saveChatSession({ - version: CHAT_SESSION_SCHEMA_VERSION, - id: 'a', - createdAt: now, - updatedAt: now - 10_000, - currentAgentName: 'claude', - messages: [], - inputHistory: [], - agentSessions: {}, - }); - - await saveChatSession({ - version: CHAT_SESSION_SCHEMA_VERSION, - id: 'b', - createdAt: now, - updatedAt: now - 1_000, - currentAgentName: 'codex', - messages: [], - inputHistory: [], - agentSessions: {}, - }); - - const latest = await loadLatestChatSession(ONE_WEEK_MS); - expect(latest?.id).toBe('b'); - expect(latest?.currentAgentName).toBe('codex'); - }); -}); diff --git a/src/ui/components/AgentSelector.tsx b/src/ui/components/AgentSelector.tsx index a7345e6..16d9263 100644 --- a/src/ui/components/AgentSelector.tsx +++ b/src/ui/components/AgentSelector.tsx @@ -7,7 +7,7 @@ import { SUPPORTED_AGENTS } from '../../lib/agents'; interface AgentSelectorProps { agents: AgentInfo[]; onSelect: (agent: AgentInfo) => void; - // Optional props for controlled mode (used in ChatApp) + // Optional props for controlled mode. currentAgent?: AgentInfo | null; selectedIndex?: number; onCancel?: () => void; @@ -68,7 +68,7 @@ export const AgentSelector: React.FC = ({ ); } - // Controlled mode (with border, used in ChatApp) + // Controlled mode with explicit navigation handlers. if (isControlled) { return ( = ({ ); } - // Standalone mode (used in chat.tsx for initial selection) + // Standalone mode. return ( diff --git a/src/ui/components/SessionSelector.tsx b/src/ui/components/SessionSelector.tsx deleted file mode 100644 index 989dbf7..0000000 --- a/src/ui/components/SessionSelector.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Box, Text } from 'ink'; -import type React from 'react'; -import { useMemo } from 'react'; - -import type { ChatSessionSummary } from '@/lib/services/session-store'; -import { GenericSelector, type SelectorItem } from './GenericSelector'; - -interface SessionSelectorProps { - sessions: ChatSessionSummary[]; - onSelect: (session: ChatSessionSummary) => void; - selectedIndex?: number; - onCancel?: () => void; - onNavigate?: (direction: 'up' | 'down') => void; -} - -function formatRelativeTime(updatedAt: number): string { - const diffMs = Date.now() - updatedAt; - if (diffMs < 0) return 'just now'; - - const sec = Math.floor(diffMs / 1000); - if (sec < 60) return `${sec}s ago`; - - const min = Math.floor(sec / 60); - if (min < 60) return `${min}m ago`; - - const hr = Math.floor(min / 60); - if (hr < 24) return `${hr}h ago`; - - const day = Math.floor(hr / 24); - return `${day}d ago`; -} - -function truncatePreview(preview: string, maxLen = 80): string { - const trimmed = preview.trim(); - if (!trimmed) return ''; - if (trimmed.length <= maxLen) return trimmed; - return `${trimmed.slice(0, Math.max(0, maxLen - 1))}…`; -} - -interface SessionItem extends SelectorItem { - session: ChatSessionSummary; - agent: string; - when: string; - preview: string; -} - -export const SessionSelector: React.FC = ({ - sessions, - onSelect, - selectedIndex, - onCancel, - onNavigate, -}) => { - const items = useMemo(() => { - return sessions.map((s) => ({ - id: s.id, - label: '', - session: s, - agent: s.currentAgentName ?? 'unknown', - when: formatRelativeTime(s.updatedAt), - preview: truncatePreview(s.preview), - })); - }, [sessions]); - - return ( - - items={items} - title="Resume Session" - onSelect={(item) => onSelect(item.session)} - selectedIndex={selectedIndex} - onCancel={onCancel} - onNavigate={onNavigate} - emptyMessage="No saved sessions found (last 7 days)." - renderItem={(item, isSelected) => { - const details = `${item.when} · ${item.agent}`; - const preview = item.preview ? ` · "${item.preview}"` : ''; - - return ( - - {isSelected ? '› ' : ' '} - - {details} - - {preview} - - ); - }} - /> - ); -}; diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index c587986..c3eaf15 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -31,7 +31,7 @@ describe('CLI E2E Tests', () => { expect(result.stdout).toContain('agent'); expect(result.stdout).toContain('install'); expect(result.stdout).toContain('doctor'); - expect(result.stdout).toContain('debug'); + expect(result.stdout).not.toContain('debug '); expect(result.stdout).toContain('install-mcp'); // Skills should NOT be in CLI help (they are chat-only) @@ -128,23 +128,20 @@ describe('CLI Help Output Formatting', () => { test('should have proper command descriptions', async () => { const result = await rig.run(['--help']); - const installDescriptionPattern = /Autonomous SDK integration|Autonomous project build/; // Check command descriptions - expect(result.stdout).toContain('Start interactive chat'); + expect(result.stdout).toContain('Show this help message'); expect(result.stdout).toContain('List or switch AI agents'); - expect(result.stdout).toMatch(installDescriptionPattern); + expect(result.stdout).toContain('Install Clix SDK'); expect(result.stdout).toContain('doctor'); - expect(result.stdout).toContain('Interactive debugging assistant'); expect(result.stdout).toContain('Install Clix MCP Server'); }); test('should list install command', async () => { const result = await rig.run(['--help']); - const installDescriptionPattern = /Autonomous SDK integration|Autonomous project build/; // install command should be listed expect(result.stdout).toContain('install'); - expect(result.stdout).toMatch(installDescriptionPattern); + expect(result.stdout).toContain('Install Clix SDK'); }); }); diff --git a/tests/e2e/command-output.test.ts b/tests/e2e/command-output.test.ts index fe0246f..c5aefab 100644 --- a/tests/e2e/command-output.test.ts +++ b/tests/e2e/command-output.test.ts @@ -15,26 +15,6 @@ describe('Command Mode Output Persistence', () => { } }); - describe('Debug command help', () => { - test('should show help and leave output in terminal', async () => { - const result = await rig.run(['debug']); - - // Should display help message - expect(result.stdout).toContain('Usage: clix debug '); - expect(result.stdout).toContain('Description:'); - expect(result.stdout).toContain('Interactive debugging assistant'); - expect(result.stdout).toContain('Examples:'); - - // Output should be captured (proving it persists) - expect(result.stdout.length).toBeGreaterThan(0); - }); - - test('should exit successfully after showing help', async () => { - const result = await rig.run(['debug']); - expect(result.exitCode).toBe(0); - }); - }); - describe('Help command', () => { test('should show main help output', async () => { const result = await rig.run(['--help']); @@ -54,7 +34,7 @@ describe('Command Mode Output Persistence', () => { // Verify that command mode commands are listed expect(result.stdout).toContain('install'); expect(result.stdout).toContain('doctor'); - expect(result.stdout).toContain('debug'); + expect(result.stdout).not.toContain('debug '); }); }); @@ -74,16 +54,14 @@ describe('Final Output Format Verification', () => { test('should verify final output format in help text', async () => { const rig = createTestRig({ timeout: 10000 }); const result = await rig.run(['--help']); - const installDescriptionPattern = /Autonomous SDK integration|Autonomous project build/; // Verify commands that support final output are listed expect(result.stdout).toContain('install'); - expect(result.stdout).toContain('debug'); expect(result.stdout).toContain('doctor'); // Note: config is not shown in help (interactive command) // These commands should now persist their output - expect(result.stdout).toMatch(installDescriptionPattern); - expect(result.stdout).toContain('Interactive debugging assistant'); + expect(result.stdout).toContain('Install Clix SDK'); + expect(result.stdout).toContain('Check Clix SDK integration status'); }); }); From bc7ba837111871af1a3e65637944142bd6fe5279 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Sat, 21 Feb 2026 23:34:48 -0800 Subject: [PATCH 15/30] refactor(cli): remove vercel skills dependency from command flow Remove Vercel Skills checks and guidance from install/doctor command execution paths. Both commands now proceed directly to agent handoff after agent selection. Also remove the vercel-skills service, drop the open dependency, and update docs to match the new behavior. --- README.md | 16 +- bun.lock | 25 -- llms.txt | 27 +-- package.json | 1 - src/commands/skill/index.tsx | 102 ++++---- .../services/__tests__/agent-handoff.test.ts | 228 ++++++++++++++++++ src/lib/services/agent-handoff.ts | 183 ++++++++++++++ src/lib/services/vercel-skills.ts | 110 --------- 8 files changed, 466 insertions(+), 226 deletions(-) create mode 100644 src/lib/services/__tests__/agent-handoff.test.ts create mode 100644 src/lib/services/agent-handoff.ts delete mode 100644 src/lib/services/vercel-skills.ts diff --git a/README.md b/README.md index 154a102..adb2789 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Command-mode AI assistant for Clix SDK installation and diagnostics. - Command-only workflow (no interactive chat mode) - Multiple AI agents: Claude, Codex, Gemini, OpenCode, Cursor, GitHub Copilot - Step-based `install` preparation flow for Firebase/iOS push setup -- One-shot prompt execution for `install` and `doctor` +- Interactive agent handoff for `install` and `doctor` - Built-in MCP installer (`install-mcp`) ## Installation @@ -41,15 +41,6 @@ brew install clix - Node.js 20+ - At least one supported AI agent installed on your machine -- Vercel Skills installed for your active agent - -Install Vercel Skills: - -```bash -npx skills add vercel-labs/agent-skills --agent codex -``` - -Replace `codex` with your agent id (`claude-code`, `gemini-cli`, `opencode`, `cursor`, `github-copilot`, ...). ## Quick Start @@ -79,7 +70,7 @@ Lists available AI agents or switches the active agent. Runs the install pipeline: 1. Preparation UI (step-by-step) -2. One-shot agent execution for SDK integration +2. Interactive handoff to the selected agent CLI with the install prompt Supported platforms: `ios`, `android`, `react-native`, `flutter`. @@ -87,7 +78,7 @@ Supported platforms: `ios`, `android`, `react-native`, `flutter`. ### `clix doctor [--platform ]` -Runs one-shot diagnostics and reports SDK integration status. +Hands off to the selected agent CLI with the doctor prompt for SDK diagnostics. ### `clix install-mcp [agent]` @@ -120,4 +111,3 @@ Runtime installation executes only after required preparation steps are complete ## Notes - `project-build` is internal-only and not exposed as a user command. -- If Vercel Skills is missing for the selected agent, Clix shows install guidance, opens the Skills repository page, and asks you to retry after installation. diff --git a/bun.lock b/bun.lock index 142af7e..86dac14 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,6 @@ "ink-text-input": "^6.0.0", "keychain": "^1.5.0", "meow": "^14.0.0", - "open": "^11.0.0", "picocolors": "^1.1.1", "plist": "^3.1.0", "react": "^19.2.3", @@ -203,8 +202,6 @@ "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], - "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -253,14 +250,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], - - "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], - "deffy": ["deffy@2.2.5", "", { "dependencies": { "typpy": "^2.0.0" } }, "sha512-6TX2cfIo97eKqWmqgMDAUulCwnveAe3K+4VGsTGPJsL3NtSEnSBFZ3sUXdS4EBhZ8GbdaZBzXQ04ton18dJrug=="], - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], - "define-property": ["define-property@1.0.0", "", { "dependencies": { "is-descriptor": "^1.0.0" } }, "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], @@ -395,18 +386,12 @@ "is-descriptor": ["is-descriptor@1.0.3", "", { "dependencies": { "is-accessor-descriptor": "^1.0.1", "is-data-descriptor": "^1.0.1" } }, "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw=="], - "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-function": ["is-function@1.0.2", "", {}, "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="], "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], - "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], - - "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], - "is-number": ["is-number@3.0.0", "", { "dependencies": { "kind-of": "^3.0.2" } }, "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg=="], "is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="], @@ -415,8 +400,6 @@ "is-windows": ["is-windows@0.1.1", "", {}, "sha512-3wf9CiLayWrH2O5E99jdTwVZyZwVckl+Gz4CkAtjssBPkawQBoPWDEyAHmwZnODQxqYduCBrlGfKQfvE/Mxh+A=="], - "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], @@ -503,8 +486,6 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], - "os-paths": ["os-paths@7.4.0", "", { "optionalDependencies": { "fsevents": "*" } }, "sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -543,8 +524,6 @@ "pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], - "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], - "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], @@ -571,8 +550,6 @@ "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], - "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -681,8 +658,6 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], "xdg-app-paths": ["xdg-app-paths@8.3.0", "", { "dependencies": { "xdg-portable": "^10.6.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-mgxlWVZw0TNWHoGmXq+NC3uhCIc55dDpAlDkMQUaIAcQzysb0kxctwv//fvuW61/nAAeUBJMQ8mnZjMmuYwOcQ=="], diff --git a/llms.txt b/llms.txt index 4b0f80d..467a6f1 100644 --- a/llms.txt +++ b/llms.txt @@ -10,32 +10,24 @@ Primary commands: - `clix install` - `clix doctor` -Each command triggers a one-shot prompt execution against the selected AI agent. +`clix install` hands off to the selected AI agent CLI in interactive mode with a prefilled prompt. +`clix doctor` also hands off to the selected AI agent CLI with a doctor prompt. ## Execution Model 1. CLI resolves command and validates flags. -2. `AgentExecutionUI` selects configured agent in command mode. -3. Clix verifies Vercel Skills availability for that agent. -4. Clix executes a single prompt (`oneShot: true`) through the agent executor. +2. For `install`, Clix runs install preparation UI first. +3. Clix selects configured agent in command mode. +4. Clix launches agent CLI directly and hands off control with command prompt (`install` or `doctor`). No chat wrapping, slash command flow, or interactive skill mode is required. ## Skills Policy - Legacy package-based skill integration is removed from runtime behavior. -- Vercel Skills (`vercel-labs/skills`) is the expected skills mechanism. - Clix only supports local command prompts (`install`, `doctor`) for execution. - `project-build` is internal-only and not exposed as a CLI command. -Install Vercel Skills example: - -```bash -npx skills add vercel-labs/agent-skills --agent codex -``` - -Use agent-specific ids (`claude-code`, `gemini-cli`, `opencode`, `cursor`, `github-copilot`, ...). - ## Commands ### `clix` / `clix help` @@ -45,7 +37,7 @@ Show help and exit. List/switch active AI agent. ### `clix install [--platform ] [--start-task ]` -Runs install preparation UI, then one-shot SDK integration execution. +Runs install preparation UI, then hands off to agent CLI interactive execution. Install preparation task order: 1. Firebase Configuration Files @@ -55,7 +47,7 @@ Install preparation task order: 5. Notification Service Extension ### `clix doctor [--platform <...>]` -Runs one-shot diagnostics for SDK integration status. +Runs diagnostics via interactive handoff to agent CLI. ### `clix install-mcp [agent]` Installs Clix MCP server for target agent. @@ -73,13 +65,12 @@ Authentication and lifecycle commands. - Doctor command wrapper: `src/commands/doctor.tsx` - Command prompt executor for install/doctor: `src/commands/skill/index.tsx` - Prompt composition: `src/lib/skills.ts` -- Vercel Skills checks: `src/lib/services/vercel-skills.ts` -- Command execution UI: `src/ui/AgentExecutionUI.tsx` +- Agent handoff service: `src/lib/services/agent-handoff.ts` - Install preparation UI: `src/ui/components/InstallPreparationUI.tsx` ## Behavioral Guarantees - Command mode only. -- One-shot execution for agent-triggered commands. +- Interactive handoff for install and doctor. - Install runtime task success is based on actual execution results, not stubs. - Firebase service account and config mismatch is surfaced as mismatch (not completed). diff --git a/package.json b/package.json index d94bef1..f1327eb 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "ink-text-input": "^6.0.0", "keychain": "^1.5.0", "meow": "^14.0.0", - "open": "^11.0.0", "picocolors": "^1.1.1", "plist": "^3.1.0", "react": "^19.2.3", diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 04b9a6a..2262f19 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -1,14 +1,13 @@ import type { AgentInfo } from '../../lib/agents'; -import type { AgentExecutor, AgentMessage } from '../../lib/executor'; -import { ensureVercelSkillsInstalled } from '../../lib/services/vercel-skills'; -import { executeSkill } from '../../lib/skills'; -import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; +import { getConfigManager } from '../../lib/config/index'; +import { buildAgentHandoffInvocation, runAgentHandoff } from '../../lib/services/agent-handoff'; +import { AgentSelectionService } from '../../lib/services/agent-selection-service'; +import { getSkillPrompt } from '../../lib/skills'; import { InstallPreparationUI } from '../../ui/components/InstallPreparationUI'; import { INSTALL_TASK_LABELS, type InstallTaskId, } from '../../ui/components/install-preparation-tasks'; -import { printFinalOutput } from '../../ui/utils/finalOutput'; import { safeRender } from '../../ui/utils/safeRender'; import type { PreparationContext } from './preparation'; @@ -20,23 +19,6 @@ interface SkillCommandOptions { type CommandSkillType = 'install' | 'doctor'; -const COMMAND_SKILLS: Record< - CommandSkillType, - { - title: string; - description: string; - } -> = { - install: { - title: 'SDK Installation', - description: 'Autonomous SDK integration with automatic file modifications', - }, - doctor: { - title: 'SDK Doctor', - description: 'Check Clix SDK integration status', - }, -}; - function isCommandSkillType(action: string): action is CommandSkillType { return action === 'install' || action === 'doctor'; } @@ -87,11 +69,20 @@ async function runInstallPreparation( }); } -async function ensureSkillsReady(agent: AgentInfo, commandName: string): Promise { - const ready = await ensureVercelSkillsInstalled(agent, commandName, process.cwd()); - if (!ready) { - throw new Error('Required Vercel Skills are not installed.'); +async function loadCommandAgent(): Promise { + const configManager = getConfigManager(); + const selectionService = new AgentSelectionService(configManager); + const result = await selectionService.selectAgent({ mode: 'command' }); + + if (!result.agent) { + return null; + } + + if (result.source === 'auto') { + await selectionService.saveSelection(result.agent); } + + return result.agent; } export async function skillCommand(options: SkillCommandOptions): Promise { @@ -104,7 +95,6 @@ export async function skillCommand(options: SkillCommandOptions): Promise const skillType: CommandSkillType = action; const projectPath = process.cwd(); - const commandInfo = COMMAND_SKILLS[skillType]; let startTaskId: InstallTaskId | undefined; if (startTask) { @@ -138,41 +128,35 @@ export async function skillCommand(options: SkillCommandOptions): Promise preparationContext = context; } - async function prepare(agent: AgentInfo): Promise { - await ensureSkillsReady(agent, `clix ${skillType}`); + const agent = await loadCommandAgent(); + if (!agent) { + throw new Error( + 'No AI agent is available. Install an agent CLI and run "clix agent" to select one.', + ); } - async function* executeCommand(executor: AgentExecutor): AsyncGenerator { - yield* executeSkill(skillType, executor, { - projectPath, - platform, - oneShot: true, - preparationContext, - }); - } + const prompt = await getSkillPrompt(skillType, { + projectPath, + platform, + preparationContext, + }); - async function* execute( - executor: AgentExecutor, - _agent: AgentInfo, - ): AsyncGenerator { - yield* executeCommand(executor); + console.log(`Handing off to ${agent.displayName} CLI...`); + const invocation = buildAgentHandoffInvocation({ + agent, + prompt, + workingDirectory: projectPath, + }); + + let exitCode = 1; + try { + exitCode = await runAgentHandoff(invocation); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to hand off to ${agent.displayName}: ${message}`); } - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - if (result) { - printFinalOutput(result); - } - resolve(); - }} - />, - ); - }); + if (exitCode !== 0) { + process.exit(exitCode); + } } diff --git a/src/lib/services/__tests__/agent-handoff.test.ts b/src/lib/services/__tests__/agent-handoff.test.ts new file mode 100644 index 0000000..e9be74d --- /dev/null +++ b/src/lib/services/__tests__/agent-handoff.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { EventEmitter } from 'node:events'; +import type { AgentInfo } from '@/lib/agents'; +import type { + AgentHandoffInvocation, + HandoffProcess, + HandoffSpawner, + HandoffSpawnOptions, +} from '../agent-handoff'; +import { buildAgentHandoffInvocation, runAgentHandoff } from '../agent-handoff'; + +class FakeHandoffProcess extends EventEmitter implements HandoffProcess { + override on(event: 'error', listener: (error: Error) => void): this; + override on(event: 'spawn', listener: () => void): this; + override on( + event: 'close', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): this; + override on( + event: 'error' | 'spawn' | 'close', + listener: + | ((error: Error) => void) + | (() => void) + | ((code: number | null, signal: NodeJS.Signals | null) => void), + ): this { + return super.on(event, listener as (...args: unknown[]) => void); + } +} + +const BASE_AGENT: Omit = { + description: 'test agent', + installUrl: 'https://example.com', + sdkPackage: 'test-package', +}; + +function createAgent(name: AgentInfo['name'], command: string, displayName: string): AgentInfo { + return { + ...BASE_AGENT, + name, + command, + displayName, + }; +} + +describe('buildAgentHandoffInvocation', () => { + test('builds claude invocation with persistent interactive flags', () => { + const invocation = buildAgentHandoffInvocation({ + agent: createAgent('claude', 'claude', 'Claude'), + prompt: 'install prompt', + workingDirectory: '/tmp/project', + }); + + expect(invocation.command).toBe('claude'); + expect(invocation.args).not.toContain('-p'); + expect(invocation.args).not.toContain('--no-session-persistence'); + expect(invocation.args).toContain('--dangerously-skip-permissions'); + expect(invocation.args).toContain('--permission-mode'); + expect(invocation.args).toContain('bypassPermissions'); + }); + + test('builds codex invocation with persistent interactive flags', () => { + const invocation = buildAgentHandoffInvocation({ + agent: createAgent('codex', 'codex', 'Codex'), + prompt: 'install prompt', + workingDirectory: '/tmp/project', + }); + + expect(invocation.args).not.toContain('exec'); + expect(invocation.args).not.toContain('--skip-git-repo-check'); + expect(invocation.args).toContain('install prompt'); + expect(invocation.args).toContain('--dangerously-bypass-approvals-and-sandbox'); + }); + + test('builds cursor invocation with workspace argument', () => { + const invocation = buildAgentHandoffInvocation({ + agent: createAgent('cursor', 'agent', 'Cursor'), + prompt: 'install prompt', + workingDirectory: '/tmp/project', + }); + + expect(invocation.args).not.toContain('-p'); + expect(invocation.args[0]).toBe('install prompt'); + expect(invocation.args).toContain('--workspace'); + expect(invocation.args).toContain('/tmp/project'); + }); + + test('injects OPENCODE_PERMISSION by default for opencode', () => { + const previous = process.env.OPENCODE_PERMISSION; + process.env.OPENCODE_PERMISSION = ''; + process.env.OPENCODE_PERMISSION = undefined; + + const invocation = buildAgentHandoffInvocation({ + agent: createAgent('opencode', 'opencode', 'OpenCode'), + prompt: 'install prompt', + workingDirectory: '/tmp/project', + }); + + expect(invocation.env?.OPENCODE_PERMISSION).toBe('{"*":"allow"}'); + + if (previous !== undefined) { + process.env.OPENCODE_PERMISSION = previous; + } else { + process.env.OPENCODE_PERMISSION = undefined; + } + }); + + test('does not override OPENCODE_PERMISSION when already set', () => { + const previous = process.env.OPENCODE_PERMISSION; + process.env.OPENCODE_PERMISSION = '{"*":"deny"}'; + + const invocation = buildAgentHandoffInvocation({ + agent: createAgent('opencode', 'opencode', 'OpenCode'), + prompt: 'install prompt', + workingDirectory: '/tmp/project', + }); + + expect(invocation.env).toBeUndefined(); + + if (previous !== undefined) { + process.env.OPENCODE_PERMISSION = previous; + } else { + process.env.OPENCODE_PERMISSION = undefined; + } + }); +}); + +describe('runAgentHandoff', () => { + let invocation: AgentHandoffInvocation; + + beforeEach(() => { + invocation = { + agent: createAgent('codex', 'codex', 'Codex'), + command: 'codex', + args: ['prompt'], + workingDirectory: '/tmp/project', + }; + }); + + test('resolves after process close with exit code', async () => { + const process = new FakeHandoffProcess(); + const spawnProcess: HandoffSpawner = (_command, _args, _options) => { + queueMicrotask(() => { + process.emit('spawn'); + process.emit('close', 0, null); + }); + return process; + }; + + const exitCode = await runAgentHandoff(invocation, spawnProcess); + expect(exitCode).toBe(0); + }); + + test('maps signal exit to conventional exit code', async () => { + const process = new FakeHandoffProcess(); + const spawnProcess: HandoffSpawner = (_command, _args, _options) => { + queueMicrotask(() => { + process.emit('spawn'); + process.emit('close', null, 'SIGTERM'); + }); + return process; + }; + + const exitCode = await runAgentHandoff(invocation, spawnProcess); + expect(exitCode).toBe(143); + }); + + test('passes expected spawn arguments and options', async () => { + const process = new FakeHandoffProcess(); + let capturedCommand: string | undefined; + let capturedArgs: string[] | undefined; + let capturedOptions: HandoffSpawnOptions | undefined; + + const invocationWithEnv: AgentHandoffInvocation = { + ...invocation, + env: { + TEST_ENV: '1', + }, + }; + + const spawnProcess: HandoffSpawner = (command, args, options) => { + capturedCommand = command; + capturedArgs = args; + capturedOptions = options; + queueMicrotask(() => { + process.emit('spawn'); + process.emit('close', 0, null); + }); + return process; + }; + + const exitCode = await runAgentHandoff(invocationWithEnv, spawnProcess); + expect(exitCode).toBe(0); + expect(capturedCommand).toBe('codex'); + expect(capturedArgs).toEqual(['prompt']); + expect(capturedOptions).toEqual({ + cwd: '/tmp/project', + stdio: 'inherit', + shell: false, + env: { + TEST_ENV: '1', + }, + }); + }); + + test('throws when process emits spawn error', async () => { + const process = new FakeHandoffProcess(); + const spawnProcess: HandoffSpawner = (_command, _args, _options) => { + queueMicrotask(() => { + process.emit('error', new Error('spawn ENOENT')); + }); + return process; + }; + + await expect(runAgentHandoff(invocation, spawnProcess)).rejects.toThrow( + 'Failed to start Codex', + ); + }); + + test('throws when spawner throws synchronously', async () => { + const spawnProcess: HandoffSpawner = () => { + throw new Error('spawn ENOENT'); + }; + + await expect(runAgentHandoff(invocation, spawnProcess)).rejects.toThrow( + 'Failed to start Codex', + ); + }); +}); diff --git a/src/lib/services/agent-handoff.ts b/src/lib/services/agent-handoff.ts new file mode 100644 index 0000000..d659da5 --- /dev/null +++ b/src/lib/services/agent-handoff.ts @@ -0,0 +1,183 @@ +import { spawn } from 'node:child_process'; +import { constants as osConstants } from 'node:os'; +import type { AgentInfo } from '@/lib/agents'; + +type ProcessWithOptionalExecve = NodeJS.Process & { + execve?: (file: string, args: string[], env?: NodeJS.ProcessEnv) => never; +}; + +export interface AgentHandoffInvocation { + agent: AgentInfo; + command: string; + args: string[]; + workingDirectory: string; + env?: NodeJS.ProcessEnv; +} + +interface BuildAgentHandoffInvocationOptions { + agent: AgentInfo; + prompt: string; + workingDirectory: string; +} + +export interface HandoffProcess { + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'spawn', listener: () => void): this; + on(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; +} + +export interface HandoffSpawnOptions { + cwd: string; + stdio: 'inherit'; + shell: false; + env?: NodeJS.ProcessEnv; +} + +export type HandoffSpawner = ( + command: string, + args: string[], + options: HandoffSpawnOptions, +) => HandoffProcess; + +function createDefaultOpenCodeEnv(): NodeJS.ProcessEnv | undefined { + if (process.env.OPENCODE_PERMISSION) { + return undefined; + } + + return { + ...process.env, + OPENCODE_PERMISSION: '{"*":"allow"}', + }; +} + +export function buildAgentHandoffInvocation({ + agent, + prompt, + workingDirectory, +}: BuildAgentHandoffInvocationOptions): AgentHandoffInvocation { + switch (agent.name) { + case 'claude': + return { + agent, + command: agent.command, + args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', prompt], + workingDirectory, + }; + + case 'codex': + return { + agent, + command: agent.command, + args: ['--dangerously-bypass-approvals-and-sandbox', prompt], + workingDirectory, + }; + + case 'gemini': + return { + agent, + command: agent.command, + args: [prompt, '-m', 'gemini-3-flash-preview', '-y'], + workingDirectory, + }; + + case 'opencode': + return { + agent, + command: agent.command, + args: [prompt], + workingDirectory, + env: createDefaultOpenCodeEnv(), + }; + + case 'cursor': + return { + agent, + command: agent.command, + args: [prompt, '-f', '--approve-mcps', '--workspace', workingDirectory], + workingDirectory, + }; + + case 'copilot': + return { + agent, + command: agent.command, + args: [prompt, '--allow-all-tools'], + workingDirectory, + }; + + default: + throw new Error(`Unsupported agent for handoff: ${agent.name}`); + } +} + +const defaultSpawner: HandoffSpawner = (command, args, options) => spawn(command, args, options); + +function getExecve(): ((file: string, args: string[], env?: NodeJS.ProcessEnv) => never) | null { + const processWithExecve = process as ProcessWithOptionalExecve; + if (typeof processWithExecve.execve !== 'function') { + return null; + } + + return processWithExecve.execve.bind(processWithExecve); +} + +function resolveExitCode(code: number | null, signal: NodeJS.Signals | null): number { + if (typeof code === 'number') { + return code; + } + + if (signal) { + const signalCode = osConstants.signals[signal]; + if (typeof signalCode === 'number') { + return 128 + signalCode; + } + return 1; + } + + return 1; +} + +export async function runAgentHandoff( + invocation: AgentHandoffInvocation, + spawnProcess: HandoffSpawner = defaultSpawner, +): Promise { + const execve = getExecve(); + if (execve) { + execve(invocation.command, [invocation.command, ...invocation.args], invocation.env); + } + + return await new Promise((resolve, reject) => { + let settled = false; + let child: HandoffProcess; + + const rejectWithSpawnError = (error: Error): void => { + if (settled) { + return; + } + + settled = true; + reject(new Error(`Failed to start ${invocation.agent.displayName}: ${error.message}`)); + }; + + try { + child = spawnProcess(invocation.command, invocation.args, { + cwd: invocation.workingDirectory, + stdio: 'inherit', + shell: false, + env: invocation.env, + }); + } catch (error) { + rejectWithSpawnError(error instanceof Error ? error : new Error(String(error))); + return; + } + + child.on('error', rejectWithSpawnError); + child.on('close', (code, signal) => { + if (settled) { + return; + } + settled = true; + resolve(resolveExitCode(code, signal)); + }); + }); +} diff --git a/src/lib/services/vercel-skills.ts b/src/lib/services/vercel-skills.ts deleted file mode 100644 index 292949a..0000000 --- a/src/lib/services/vercel-skills.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { existsSync, readdirSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import open from 'open'; -import type { AgentInfo } from '@/lib/agents'; - -const VERCEL_SKILLS_REPO_URL = 'https://github.com/vercel-labs/skills'; -const VERCEL_AGENT_SKILLS_REPO = 'vercel-labs/agent-skills'; - -interface SkillsAgentMapping { - skillsAgentId: string; - projectPath: string[]; - globalPath: string[]; -} - -const AGENT_SKILLS_MAPPINGS: Record = { - claude: { - skillsAgentId: 'claude-code', - projectPath: ['.claude', 'skills'], - globalPath: ['.claude', 'skills'], - }, - codex: { - skillsAgentId: 'codex', - projectPath: ['.agents', 'skills'], - globalPath: ['.codex', 'skills'], - }, - gemini: { - skillsAgentId: 'gemini-cli', - projectPath: ['.agents', 'skills'], - globalPath: ['.gemini', 'skills'], - }, - opencode: { - skillsAgentId: 'opencode', - projectPath: ['.agents', 'skills'], - globalPath: ['.config', 'opencode', 'skills'], - }, - cursor: { - skillsAgentId: 'cursor', - projectPath: ['.agents', 'skills'], - globalPath: ['.cursor', 'skills'], - }, - copilot: { - skillsAgentId: 'github-copilot', - projectPath: ['.agents', 'skills'], - globalPath: ['.copilot', 'skills'], - }, -}; - -function resolveAgentMapping(agentName: string): SkillsAgentMapping { - return ( - AGENT_SKILLS_MAPPINGS[agentName] ?? { - skillsAgentId: 'codex', - projectPath: ['.agents', 'skills'], - globalPath: ['.config', 'agents', 'skills'], - } - ); -} - -function hasInstalledSkillsAtPath(path: string): boolean { - if (!existsSync(path)) { - return false; - } - - const entries = readdirSync(path, { withFileTypes: true }); - return entries.some((entry) => entry.isDirectory() && !entry.name.startsWith('.')); -} - -export function hasVercelSkillsInstalled( - agentName: string, - projectPath: string = process.cwd(), -): boolean { - const mapping = resolveAgentMapping(agentName); - const projectSkillsPath = join(projectPath, ...mapping.projectPath); - const globalSkillsPath = join(homedir(), ...mapping.globalPath); - - return hasInstalledSkillsAtPath(projectSkillsPath) || hasInstalledSkillsAtPath(globalSkillsPath); -} - -export async function ensureVercelSkillsInstalled( - agent: AgentInfo, - commandName: string, - projectPath: string = process.cwd(), -): Promise { - if (hasVercelSkillsInstalled(agent.name, projectPath)) { - return true; - } - - const mapping = resolveAgentMapping(agent.name); - const installCommand = `npx skills add ${VERCEL_AGENT_SKILLS_REPO} --agent ${mapping.skillsAgentId}`; - - console.error(''); - console.error( - `Vercel Skills is required before running '${commandName}' with ${agent.displayName}.`, - ); - console.error('Open the repository guide and install skills, then retry this command:'); - console.error(` ${installCommand}`); - console.error(''); - console.error('After installation completes, run the same clix command again.'); - - try { - await open(VERCEL_SKILLS_REPO_URL); - console.error(`Opened ${VERCEL_SKILLS_REPO_URL} in your browser.`); - } catch { - console.error( - `Could not open browser automatically. Open this URL manually: ${VERCEL_SKILLS_REPO_URL}`, - ); - } - - return false; -} From 400961e247df915786704e6cf53302a2eec484f8 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Sun, 22 Feb 2026 00:18:38 -0800 Subject: [PATCH 16/30] feat: Introduce new `skills` and `mcp` commands, refactor autonomous skill installation workflow, and remove explicit installation phases. --- .github/ISSUE_TEMPLATE/bug_report.yml | 35 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- README.md | 11 +- llms.txt | 7 +- src/cli.tsx | 21 +- src/commands/__tests__/mcp.test.ts | 57 +++ src/commands/__tests__/skills.test.ts | 25 ++ src/commands/install-mcp.tsx | 126 ------ src/commands/mcp.ts | 63 +++ src/commands/skill/index.tsx | 4 +- src/commands/skill/preparation.ts | 54 ++- src/commands/skills.ts | 30 ++ src/lib/__tests__/embedded-skills.test.ts | 2 +- src/lib/__tests__/skills.test.ts | 72 +++- src/lib/api/types.ts | 8 +- src/lib/config/project-config-manager.ts | 4 +- src/lib/config/project-config-schema.ts | 36 +- src/lib/constants/system-prompt.ts | 2 +- src/lib/embedded-skills.ts | 97 ++--- .../services/__tests__/agent-handoff.test.ts | 62 ++- .../__tests__/mcp-install-service.test.ts | 219 ++++++---- src/lib/services/agent-handoff.ts | 50 ++- src/lib/services/mcp-install-service.ts | 395 ++++++------------ src/lib/skills.ts | 65 ++- src/lib/skills/install/SKILL.md | 97 ++--- src/ui/LoginUI.tsx | 4 +- src/ui/SetupUI.tsx | 4 +- src/ui/components/AppleLoginUI.tsx | 2 +- src/ui/components/MCPInstallSelector.tsx | 87 ---- .../components/push-setup/PushSetupTasks.tsx | 19 +- tests/e2e/cli.test.ts | 6 +- tests/e2e/command-output.test.ts | 1 + 32 files changed, 860 insertions(+), 807 deletions(-) create mode 100644 src/commands/__tests__/mcp.test.ts create mode 100644 src/commands/__tests__/skills.test.ts delete mode 100644 src/commands/install-mcp.tsx create mode 100644 src/commands/mcp.ts create mode 100644 src/commands/skills.ts delete mode 100644 src/ui/components/MCPInstallSelector.tsx diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a54a1b9..508f3ed 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,7 +31,7 @@ body: label: What happened? description: Describe what's happening that shouldn't be placeholder: | - When I run `clix integrate`, the CLI shows an error... + When I run `clix install`, the CLI shows an error... validations: required: true @@ -52,9 +52,9 @@ body: Please provide clear, numbered steps that anyone can follow to reproduce the issue. Include any necessary context or configuration. placeholder: | - 1. Run `clix` to start interactive mode - 2. Type `/integrate` - 3. Select iOS platform + 1. Run `clix install` + 2. Complete preparation steps + 3. CLI hands off to the selected agent 4. Error appears: "..." validations: required: true @@ -73,20 +73,23 @@ body: required: true - type: dropdown - id: skill + id: command attributes: - label: Related Skill/Command - description: Which skill or command was involved? + label: Related Command + description: Which command was involved? options: - - Interactive Chat (default) - - /integrate (SDK Integration) - - /track (Event Tracking) - - /user (User Management) - - /personalize (Personalization) - - /doctor (SDK Doctor) - - /debug (Debugging Assistant) - - install-mcp - - config + - install + - doctor + - mcp + - skills + - agent + - login + - logout + - whoami + - update + - uninstall + - setup + - help - Other validations: required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 5b8e113..6f4b129 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -45,7 +45,7 @@ body: How would you like this to work? Describe the ideal user experience. Be specific about how you'd interact with this feature. placeholder: | - I'd like a new command `/migrate` that helps upgrade from older SDK versions... + I'd like a new command `clix migrate` that helps upgrade from older SDK versions... The interface should show... validations: diff --git a/README.md b/README.md index adb2789..ae82b52 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Command-mode AI assistant for Clix SDK installation and diagnostics. - Multiple AI agents: Claude, Codex, Gemini, OpenCode, Cursor, GitHub Copilot - Step-based `install` preparation flow for Firebase/iOS push setup - Interactive agent handoff for `install` and `doctor` -- Built-in MCP installer (`install-mcp`) +- Built-in MCP installer (`mcp`) +- Built-in skills installer (`skills`) ## Installation @@ -80,9 +81,13 @@ Supported platforms: `ios`, `android`, `react-native`, `flutter`. Hands off to the selected agent CLI with the doctor prompt for SDK diagnostics. -### `clix install-mcp [agent]` +### `clix mcp [agent]` -Installs Clix MCP server for the selected/specified agent. +Hands off to `npx add-mcp` to install Clix MCP server (`clix-so/clix-mcp-server`). + +### `clix skills` + +Hands off to `npx skills add clix-so/skills` and transfers CLI control. ### `clix update [--dry-run] [--force]` diff --git a/llms.txt b/llms.txt index 467a6f1..06dd58b 100644 --- a/llms.txt +++ b/llms.txt @@ -49,8 +49,11 @@ Install preparation task order: ### `clix doctor [--platform <...>]` Runs diagnostics via interactive handoff to agent CLI. -### `clix install-mcp [agent]` -Installs Clix MCP server for target agent. +### `clix mcp [agent]` +Hands off to `npx add-mcp` to install Clix MCP server. + +### `clix skills` +Hands off to `npx skills add clix-so/skills` and transfers CLI control. ### `clix update [--dry-run] [--force]` Checks and optionally applies CLI updates. diff --git a/src/cli.tsx b/src/cli.tsx index 6312468..5b4760a 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -2,10 +2,11 @@ import meow from 'meow'; import { agentCommand } from './commands/agent'; import { doctorCommand } from './commands/doctor'; import { installCommand } from './commands/install'; -import { installMCPCommand } from './commands/install-mcp'; import { loginCommand } from './commands/login'; import { logoutCommand } from './commands/logout'; +import { mcpCommand } from './commands/mcp'; import { setupCommand } from './commands/setup'; +import { skillsCommand } from './commands/skills'; import { uninstallCommand } from './commands/uninstall'; import { updateCommand } from './commands/update'; import { whoamiCommand } from './commands/whoami'; @@ -31,9 +32,10 @@ function generateHelpText(): string { logout Log out from Clix whoami Show current logged-in user agent [name] List or switch AI agents - install Install Clix SDK (step-by-step setup + one-shot execution) + install Install Clix SDK (step-by-step setup + interactive agent handoff) doctor Check Clix SDK integration status - install-mcp [agent] Install Clix MCP Server + mcp [agent] Install Clix MCP Server + skills Install Clix skill package via skills CLI uninstall Uninstall Clix CLI from your system update Check for available updates @@ -52,8 +54,9 @@ function generateHelpText(): string { $ clix agent claude $ clix install $ clix doctor - $ clix install-mcp - $ clix install-mcp claude + $ clix mcp + $ clix mcp claude + $ clix skills `; } @@ -143,7 +146,7 @@ async function main() { break; } - case 'install-mcp': { + case 'mcp': { const agentInput = cli.input[1]; const validAgents = getValidMCPAgents(); if (agentInput && !isValidMCPAgent(agentInput)) { @@ -152,10 +155,14 @@ async function main() { process.exit(1); } const agent = agentInput as MCPTargetAgent | undefined; - await installMCPCommand({ agent }); + await mcpCommand({ agent }); break; } + case 'skills': + await skillsCommand(); + break; + case 'update': case 'upgrade': await updateCommand({ diff --git a/src/commands/__tests__/mcp.test.ts b/src/commands/__tests__/mcp.test.ts new file mode 100644 index 0000000..1ba7768 --- /dev/null +++ b/src/commands/__tests__/mcp.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { CommandHandoffInvocation } from '@/lib/services/agent-handoff'; + +const runCommandHandoffMock = mock(async (_invocation: CommandHandoffInvocation) => 0); + +import { mcpCommand } from '../mcp'; + +describe('mcpCommand', () => { + beforeEach(() => { + runCommandHandoffMock.mockReset(); + runCommandHandoffMock.mockResolvedValue(0); + }); + + test('hands off with mapped add-mcp agent when agent is provided', async () => { + await mcpCommand({ agent: 'codex' }, { runHandoff: runCommandHandoffMock }); + + expect(runCommandHandoffMock).toHaveBeenCalledTimes(1); + expect(runCommandHandoffMock).toHaveBeenCalledWith({ + command: 'npx', + args: [ + '-y', + 'add-mcp', + 'npx -y https://github.com/clix-so/clix-mcp-server', + '--name', + 'clix-mcp-server', + '--global', + '--agent', + 'codex', + ], + workingDirectory: process.cwd(), + displayName: 'add-mcp', + }); + }); + + test('hands off without --agent when agent is omitted', async () => { + await mcpCommand({}, { runHandoff: runCommandHandoffMock }); + + expect(runCommandHandoffMock).toHaveBeenCalledTimes(1); + const invocation = runCommandHandoffMock.mock.calls[0]?.[0]; + + expect(invocation).toBeDefined(); + if (!invocation) { + return; + } + + expect(invocation.command).toBe('npx'); + expect(invocation.displayName).toBe('add-mcp'); + expect(invocation.args).toEqual([ + '-y', + 'add-mcp', + 'npx -y https://github.com/clix-so/clix-mcp-server', + '--name', + 'clix-mcp-server', + '--global', + ]); + }); +}); diff --git a/src/commands/__tests__/skills.test.ts b/src/commands/__tests__/skills.test.ts new file mode 100644 index 0000000..98cd6a2 --- /dev/null +++ b/src/commands/__tests__/skills.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { CommandHandoffInvocation } from '@/lib/services/agent-handoff'; + +const runCommandHandoffMock = mock(async (_invocation: CommandHandoffInvocation) => 0); + +import { skillsCommand } from '../skills'; + +describe('skillsCommand', () => { + beforeEach(() => { + runCommandHandoffMock.mockReset(); + runCommandHandoffMock.mockResolvedValue(0); + }); + + test('hands off to npx skills add clix-so/skills', async () => { + await skillsCommand({ runHandoff: runCommandHandoffMock }); + + expect(runCommandHandoffMock).toHaveBeenCalledTimes(1); + expect(runCommandHandoffMock).toHaveBeenCalledWith({ + command: 'npx', + args: ['skills', 'add', 'clix-so/skills'], + workingDirectory: process.cwd(), + displayName: 'Skills CLI', + }); + }); +}); diff --git a/src/commands/install-mcp.tsx b/src/commands/install-mcp.tsx deleted file mode 100644 index 325302f..0000000 --- a/src/commands/install-mcp.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Box, Text } from 'ink'; -import React, { useCallback, useState } from 'react'; -import { - getMCPAgentDisplayName, - installMCPServer, - type MCPTargetAgent, -} from '../lib/services/mcp-install-service'; -import { MCPInstallSelector } from '../ui/components/MCPInstallSelector'; -import { StatusMessage } from '../ui/components/StatusMessage'; -import { type FinalOutputResult, printFinalOutput } from '../ui/utils/finalOutput'; -import { safeRender } from '../ui/utils/safeRender'; - -interface InstallMCPCommandOptions { - agent?: MCPTargetAgent; -} - -interface MCPInstallUIProps { - targetAgent?: MCPTargetAgent; - onComplete: (result?: FinalOutputResult) => void; -} - -const MCPInstallUI: React.FC = ({ targetAgent, onComplete }) => { - const [installing, setInstalling] = useState(false); - const [result, setResult] = useState<{ - success: boolean; - message: string; - error?: string; - } | null>(null); - - const handleSelect = useCallback( - async (agent: MCPTargetAgent) => { - setInstalling(true); - const installResult = await installMCPServer(agent); - setResult(installResult); - setInstalling(false); - - setTimeout(() => { - const finalResult: FinalOutputResult = { - type: installResult.success ? 'success' : 'error', - title: installResult.success - ? 'MCP Server installation completed' - : 'MCP Server installation failed', - message: installResult.message, - details: installResult.error ? [installResult.error] : undefined, - }; - onComplete(finalResult); - }, 2000); - }, - [onComplete], - ); - - // If target agent is specified, install directly - React.useEffect(() => { - if (targetAgent) { - handleSelect(targetAgent); - } - }, [targetAgent, handleSelect]); - - if (targetAgent) { - // Direct installation mode - return ( - - {installing && ( - - )} - {result && - (result.success ? ( - - ) : ( - - - {result.error && {result.error}} - - ))} - - ); - } - - // Interactive selection mode - if (result) { - return ( - - {result.success ? ( - - ) : ( - - - {result.error && {result.error}} - - )} - - ); - } - - if (installing) { - return ( - - - - ); - } - - return ; -}; - -export async function installMCPCommand(options: InstallMCPCommandOptions): Promise { - const { agent } = options; - - return new Promise((resolve) => { - const { unmount } = safeRender( - { - unmount(); - if (result) { - printFinalOutput(result); - } - resolve(); - }} - />, - ); - }); -} diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..293e15b --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,63 @@ +import { runCommandHandoff } from '../lib/services/agent-handoff'; +import { getMCPAgentConfigs, type MCPTargetAgent } from '../lib/services/mcp-install-service'; + +const ADD_MCP_PACKAGE = 'add-mcp'; +const CLIX_MCP_SERVER_REPO = 'https://github.com/clix-so/clix-mcp-server'; +const MCP_SERVER_NAME = 'clix-mcp-server'; + +interface MCPCommandOptions { + agent?: MCPTargetAgent; +} + +interface MCPCommandDependencies { + runHandoff?: typeof runCommandHandoff; +} + +function buildMcpHandoffArgs(agent?: MCPTargetAgent): string[] { + const args = [ + '-y', + ADD_MCP_PACKAGE, + `npx -y ${CLIX_MCP_SERVER_REPO}`, + '--name', + MCP_SERVER_NAME, + '--global', + ]; + + if (!agent) { + return args; + } + + const targetAgentConfig = getMCPAgentConfigs().find((config) => config.name === agent); + if (!targetAgentConfig) { + throw new Error(`Unknown agent: ${agent}`); + } + + args.push('--agent', targetAgentConfig.addMcpAgentId); + return args; +} + +export async function mcpCommand( + options: MCPCommandOptions, + dependencies: MCPCommandDependencies = {}, +): Promise { + const runHandoff = dependencies.runHandoff ?? runCommandHandoff; + + console.log('Adding Clix MCP Server...'); + + let exitCode = 1; + try { + exitCode = await runHandoff({ + command: 'npx', + args: buildMcpHandoffArgs(options.agent), + workingDirectory: process.cwd(), + displayName: 'add-mcp', + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to launch add-mcp: ${message}`); + } + + if (exitCode !== 0) { + process.exit(exitCode); + } +} diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 2262f19..3781e01 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -141,7 +141,7 @@ export async function skillCommand(options: SkillCommandOptions): Promise preparationContext, }); - console.log(`Handing off to ${agent.displayName} CLI...`); + console.log(`Launching ${agent.displayName}...`); const invocation = buildAgentHandoffInvocation({ agent, prompt, @@ -153,7 +153,7 @@ export async function skillCommand(options: SkillCommandOptions): Promise exitCode = await runAgentHandoff(invocation); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to hand off to ${agent.displayName}: ${message}`); + throw new Error(`Failed to launch ${agent.displayName}: ${message}`); } if (exitCode !== 0) { diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts index 059c889..6a9d454 100644 --- a/src/commands/skill/preparation.ts +++ b/src/commands/skill/preparation.ts @@ -200,6 +200,54 @@ export async function ensureProjectType( return { config: updatedConfig, projectType }; } +async function ensureProjectPublicKey( + config: ProjectConfig, + projectPath: string, +): Promise { + const configuredPublicApiKey = config.project.public_api_key ?? config.project.publicKey; + if (config.project.public_api_key && !config.project.publicKey && configuredPublicApiKey) { + return config; + } + + const manager = getProjectConfigManager(projectPath); + + if (configuredPublicApiKey) { + const normalizedConfig: ProjectConfig = { + ...config, + project: { + id: config.project.id, + name: config.project.name, + public_api_key: configuredPublicApiKey, + }, + }; + await manager.save(normalizedConfig); + return normalizedConfig; + } + + try { + const project = await getInternalApiClient().getProject(config.project.id); + const projectPublicKey = project.public_api_key ?? project.public_key; + + if (!projectPublicKey) { + return config; + } + + const updatedConfig: ProjectConfig = { + ...config, + project: { + id: config.project.id, + name: config.project.name, + public_api_key: projectPublicKey, + }, + }; + + await manager.save(updatedConfig); + return updatedConfig; + } catch { + return config; + } +} + function getFirebaseProjectIdsFromDetection(detection: FirebaseDetectionResult): string[] { const detectedProjectIds: string[] = []; @@ -552,7 +600,11 @@ export async function gatherPreparationContext( const config = linkStatus.config; // Step 2: Ensure project type is detected - const { config: updatedConfig, projectType } = await ensureProjectType(config, projectPath); + const { config: configWithProjectType, projectType } = await ensureProjectType( + config, + projectPath, + ); + const updatedConfig = await ensureProjectPublicKey(configWithProjectType, projectPath); // Step 3: Check Firebase status const firebase = await checkFirebaseStatus( diff --git a/src/commands/skills.ts b/src/commands/skills.ts new file mode 100644 index 0000000..480eed7 --- /dev/null +++ b/src/commands/skills.ts @@ -0,0 +1,30 @@ +import { runCommandHandoff } from '../lib/services/agent-handoff'; + +const DEFAULT_SKILLS_REPOSITORY = 'clix-so/skills'; + +interface SkillsCommandDependencies { + runHandoff?: typeof runCommandHandoff; +} + +export async function skillsCommand(dependencies: SkillsCommandDependencies = {}): Promise { + const runHandoff = dependencies.runHandoff ?? runCommandHandoff; + + console.log('Adding Clix Skills...'); + + let exitCode = 1; + try { + exitCode = await runHandoff({ + command: 'npx', + args: ['skills', 'add', DEFAULT_SKILLS_REPOSITORY], + workingDirectory: process.cwd(), + displayName: 'Skills CLI', + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to launch Skills CLI: ${message}`); + } + + if (exitCode !== 0) { + process.exit(exitCode); + } +} diff --git a/src/lib/__tests__/embedded-skills.test.ts b/src/lib/__tests__/embedded-skills.test.ts index 8f0ee77..686203b 100644 --- a/src/lib/__tests__/embedded-skills.test.ts +++ b/src/lib/__tests__/embedded-skills.test.ts @@ -19,7 +19,7 @@ describe('embedded-skills (local prompts only)', () => { test('returns embedded prompt by folder key', () => { const installPrompt = getEmbeddedSkill('local-install'); expect(installPrompt).toBeDefined(); - expect(installPrompt).toContain('Install phase'); + expect(installPrompt).toContain('Integration Workflow'); }); test('returns all embedded prompt keys', () => { diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index f4fd5b9..1756a8c 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -1,4 +1,5 @@ import { describe, expect, mock, test } from 'bun:test'; +import type { PreparationContext } from '@/commands/skill/preparation'; import type { AgentMessage, ExecuteOptions } from '../executor'; import { AVAILABLE_SKILLS, @@ -32,17 +33,80 @@ describe('getSkillPrompt', () => { expect(prompt).toContain('analyzing a mobile project for Clix SDK'); }); - test('builds install prompt in integration phase by default', async () => { + test('builds install prompt with integration goal', async () => { const prompt = await getSkillPrompt('install', { projectPath: '/tmp/project', platform: 'ios', - oneShot: true, }); expect(prompt).toContain('Project path: /tmp/project'); expect(prompt).toContain('Target platform: ios'); - expect(prompt).toContain('Install phase: integration'); - expect(prompt).toContain('EXECUTION MODE: AUTONOMOUS'); + expect(prompt).toContain( + 'Execution goal: Complete SDK integration workflow using the pre-configured setup context.', + ); + expect(prompt).not.toContain('non-interactive one-shot execution'); + expect(prompt).not.toContain('Install phase:'); + expect(prompt).not.toContain('project-build'); + }); + + test('includes Project Public API Key in install prompt when available', async () => { + const preparationContext: PreparationContext = { + projectPath: '/tmp/project', + config: { + version: 1, + member: { + id: 'member-1', + email: 'member@example.com', + name: 'Member', + }, + organization: { + id: 'org-1', + name: 'Org', + }, + project: { + id: 'project-1', + name: 'Project', + public_api_key: 'pk_test_public_key_123', + }, + linkedAt: '2026-01-01T00:00:00.000Z', + }, + projectType: { + framework: 'native', + target: 'ios', + }, + firebase: { + configured: true, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + senderConfigProjectMatched: true, + projectId: 'firebase-project', + needed: true, + }, + ios: { + needed: true, + bundleId: 'com.example.app', + teamId: 'TEAM123456', + appGroupId: 'group.clix.com.example.app', + entitlementsConfigured: true, + nseConfigured: true, + }, + apns: { + needed: true, + keyId: 'KEY1234567', + teamId: 'TEAM123456', + registeredWithFirebase: true, + }, + ready: true, + missing: [], + }; + + const prompt = await getSkillPrompt('install', { + projectPath: '/tmp/project', + preparationContext, + }); + + expect(prompt).toContain('Project Public API Key: pk_test_public_key_123'); }); test('rejects non-local skill types', async () => { diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index bb62ecb..61cf7a0 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -24,11 +24,17 @@ export interface Organization { /** * Project information from Internal API. */ +export interface ProjectPublicApiKey { + key: string; + restrictions?: unknown[]; +} + export interface Project { id: string; name: string; organization_id: string; - public_key?: string; + public_api_key?: string | ProjectPublicApiKey; + public_key?: string | ProjectPublicApiKey; sender_configs?: SenderConfig[]; created_at?: string; updated_at?: string; diff --git a/src/lib/config/project-config-manager.ts b/src/lib/config/project-config-manager.ts index e8d350e..b1df69f 100644 --- a/src/lib/config/project-config-manager.ts +++ b/src/lib/config/project-config-manager.ts @@ -132,9 +132,11 @@ export class ProjectConfigManager { // Migrate to latest version if needed const migratedConfig = ensureLatestVersion(validatedConfig); + const migratedConfigChanged = + JSON.stringify(validatedConfig) !== JSON.stringify(migratedConfig); // Save migrated config if version changed - if (validatedConfig.version !== CURRENT_PROJECT_CONFIG_VERSION) { + if (validatedConfig.version !== CURRENT_PROJECT_CONFIG_VERSION || migratedConfigChanged) { await this.save(migratedConfig); } diff --git a/src/lib/config/project-config-schema.ts b/src/lib/config/project-config-schema.ts index 639cf05..0a6dfff 100644 --- a/src/lib/config/project-config-schema.ts +++ b/src/lib/config/project-config-schema.ts @@ -47,13 +47,23 @@ export const ProjectOrganizationSchema = z.object({ /** * Schema for project information in project config. */ +export const ProjectPublicApiKeySchema = z.union([ + z.string(), + z.object({ + key: z.string(), + restrictions: z.array(z.unknown()).optional(), + }), +]); + export const ProjectInfoSchema = z.object({ /** Project ID */ id: z.string(), /** Project name */ name: z.string(), - /** Project public key (for SDK integration) */ - publicKey: z.string().optional(), + /** Project public API key (for SDK integration), matches Internal API response key */ + public_api_key: ProjectPublicApiKeySchema.optional(), + /** Legacy project public key field (backward compatibility) */ + publicKey: ProjectPublicApiKeySchema.optional(), }); /** @@ -141,6 +151,7 @@ export const ProjectConfigSchema = z.object({ export type ProjectMember = z.infer; export type ProjectOrganization = z.infer; export type ProjectInfo = z.infer; +export type ProjectPublicApiKey = z.infer; export type IosSetup = z.infer; export type FirebaseSetup = z.infer; export type ApnsSetup = z.infer; @@ -193,7 +204,22 @@ export function safeValidateProjectConfig(data: unknown): ProjectConfig | null { * @returns Config at latest version */ export function ensureLatestVersion(config: ProjectConfig): ProjectConfig { - // Currently no migration needed - // Add migration logic here when schema changes in future versions - return config; + const publicApiKey = config.project.public_api_key ?? config.project.publicKey; + + if (!publicApiKey && !config.project.public_api_key && !config.project.publicKey) { + return config; + } + + if (config.project.public_api_key && !config.project.publicKey) { + return config; + } + + return { + ...config, + project: { + id: config.project.id, + name: config.project.name, + ...(publicApiKey && { public_api_key: publicApiKey }), + }, + }; } diff --git a/src/lib/constants/system-prompt.ts b/src/lib/constants/system-prompt.ts index 4ad9eb2..3531bf2 100644 --- a/src/lib/constants/system-prompt.ts +++ b/src/lib/constants/system-prompt.ts @@ -73,7 +73,7 @@ Platform-specific configs to verify: - /api-triggered-campaigns - API-triggered campaign setup - /doctor - Check SDK integration status - /debug - Debug and investigate user-reported problems -- /install-mcp - Install Clix MCP Server for AI agents +- /mcp - Install Clix MCP Server for AI agents ## Response Style - Clear, concise with bullet points diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index 4b45541..f0f2010 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -24,41 +24,22 @@ export interface SkillMetadata { export const EMBEDDED_SKILLS: Record = { 'local-install': `# Clix SDK Autonomous Install -You are an autonomous AI agent that handles \`/install\` runtime work for Clix SDK projects. +You are an autonomous AI agent that handles \`clix install\` runtime integration work. ## Scope -This single skill is used for both phases: -- \`Install phase: project-build\` -- \`Install phase: integration\` +This skill runs after \`clix install\` preparation has validated required setup. -Do not perform or re-describe setup steps already handled by \`/install\` preparation tasks: +Do not perform or re-describe setup tasks already handled by preparation: - Firebase configuration files - APNS key registration in Firebase - Firebase service account sender config - iOS entitlements setup - Notification Service Extension setup -## Phase Contract - -Read the phase line from the prompt header and follow it strictly. - -### When phase is \`project-build\` -- Run **only** project build workflow. -- Validate project type quickly from file evidence. -- Choose and run the right build command. -- If build fails, apply minimal fixes and retry autonomously. -- Do not add new SDK integration changes unless required to fix build errors. - -### When phase is \`integration\` -- Run SDK integration workflow (dependency + initialization + verification). -- Apply required file changes directly. -- Run dependency/install commands needed for resolution. -- Run build commands only for verification after integration changes. - ## Core Directive -**MODIFY FILES DIRECTLY**. Do not ask for confirmation. +**Modify files directly. Do not ask for confirmation.** ## Supported Platforms & Docs @@ -67,11 +48,11 @@ Read the phase line from the prompt header and follow it strictly. - React Native: https://docs.clix.so/sdk-quickstart-react-native - Flutter: https://docs.clix.so/sdk-quickstart-flutter -## Project Build Workflow (phase: project-build) +## Integration Workflow -### 1. Validate provided project type quickly +### 1. Validate context and project type -Start from provided \`/install\` context, then confirm with file indicators: +Use the provided preparation context first, then confirm quickly with file evidence: - \`Podfile\` + \`*.xcworkspace\` -> iOS native (CocoaPods) - \`*.xcodeproj\` + \`Package.swift\` OR \`*.xcodeproj\` without Podfile -> iOS native (SPM) - \`build.gradle\` or \`build.gradle.kts\` -> Android @@ -81,47 +62,6 @@ Start from provided \`/install\` context, then confirm with file indicators: If context and file evidence conflict, use file evidence and report the mismatch. -### 2. Build command selection - -Prefer project-specific custom commands if valid (\`CLAUDE.md\`, \`Makefile\`, \`package.json\` scripts, \`Justfile\`, \`Taskfile.yml\`, \`fastlane/Fastfile\`). - -Default commands: -- iOS CocoaPods: - - \`pod install --project-directory=\` - - \`xcodebuild -workspace .xcworkspace -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build\` -- iOS SPM: - - \`xcodebuild -project .xcodeproj -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build\` -- Android: \`./gradlew assembleDebug\` -- React Native: \`npx react-native run-ios\` or \`npx react-native run-android\` -- Expo: \`npx expo run:ios\` or \`npx expo run:android\` -- Flutter: \`flutter build ios --debug --no-codesign\` or \`flutter build apk --debug\` - -For iOS \`xcodebuild\`, discover simulator first: -- \`xcrun simctl list devices available -j\` - -### 3. Failure handling - -On failure: -- Identify root cause quickly. -- Apply minimal targeted fixes. -- Retry automatically. -- Repeat until success or hard blocker. - -Hard blocker examples: -- missing secrets/credentials -- account permission limitations -- unavailable external services - -## SDK Integration Workflow (phase: integration) - -### 1. Detect platform and dependency manager - -Use project files to detect platform: -- iOS native (SPM / CocoaPods) -- Android native (Gradle) -- React Native / Expo (npm, yarn, pnpm, bun) -- Flutter (pubspec) - ### 2. Add Clix SDK dependency Apply dependency changes directly: @@ -134,25 +74,38 @@ Apply dependency changes directly: ### 3. Integrate SDK initialization Add initialization code in the real app startup path. -Use placeholders if credentials are missing: + +Use provided values from preparation context when available: +- Clix Project ID +- Clix Project Public API Key + +If values are not available, use placeholders: - \`YOUR_CLIX_PROJECT_ID\` - \`YOUR_CLIX_PUBLIC_API_KEY\` -### 4. Run post-change install commands +### 4. Run post-change dependency commands Run only necessary dependency-resolution commands: - \`npm install\` / \`yarn install\` / \`pnpm install\` / \`bun install\` - \`pod install\` (if Podfile changed) - \`flutter pub get\` -### 5. Verify integration artifacts +### 5. Verify integration and fix failures Verify: -- dependency declarations added correctly +- dependency declarations were added correctly - initialization exists in startup path - imports and references resolve - no duplicate initialization +Run build commands for verification after integration changes. + +If verification build fails: +- identify root cause quickly +- apply minimal targeted fixes +- retry automatically +- stop only on hard blockers (credentials/permissions/external service unavailable) + ## Rules Do: @@ -161,7 +114,7 @@ Do: - preserve existing code style Do not: -- redo Firebase/APNS/Entitlements/NSE preparation tasks +- redo preparation tasks - ask user to manually apply edits - add speculative abstractions diff --git a/src/lib/services/__tests__/agent-handoff.test.ts b/src/lib/services/__tests__/agent-handoff.test.ts index e9be74d..ebddd30 100644 --- a/src/lib/services/__tests__/agent-handoff.test.ts +++ b/src/lib/services/__tests__/agent-handoff.test.ts @@ -3,11 +3,12 @@ import { EventEmitter } from 'node:events'; import type { AgentInfo } from '@/lib/agents'; import type { AgentHandoffInvocation, + CommandHandoffInvocation, HandoffProcess, HandoffSpawner, HandoffSpawnOptions, } from '../agent-handoff'; -import { buildAgentHandoffInvocation, runAgentHandoff } from '../agent-handoff'; +import { buildAgentHandoffInvocation, runAgentHandoff, runCommandHandoff } from '../agent-handoff'; class FakeHandoffProcess extends EventEmitter implements HandoffProcess { override on(event: 'error', listener: (error: Error) => void): this; @@ -226,3 +227,62 @@ describe('runAgentHandoff', () => { ); }); }); + +describe('runCommandHandoff', () => { + test('runs command handoff with inherited stdio', async () => { + const process = new FakeHandoffProcess(); + let capturedCommand: string | undefined; + let capturedArgs: string[] | undefined; + let capturedOptions: HandoffSpawnOptions | undefined; + + const invocation: CommandHandoffInvocation = { + command: 'npx', + args: ['skills', 'add', 'clix-so/skills'], + workingDirectory: '/tmp/project', + env: { TEST_ENV: '1' }, + displayName: 'Skills CLI', + }; + + const spawnProcess: HandoffSpawner = (command, args, options) => { + capturedCommand = command; + capturedArgs = args; + capturedOptions = options; + queueMicrotask(() => { + process.emit('spawn'); + process.emit('close', 0, null); + }); + return process; + }; + + const exitCode = await runCommandHandoff(invocation, spawnProcess); + expect(exitCode).toBe(0); + expect(capturedCommand).toBe('npx'); + expect(capturedArgs).toEqual(['skills', 'add', 'clix-so/skills']); + expect(capturedOptions).toEqual({ + cwd: '/tmp/project', + stdio: 'inherit', + shell: false, + env: { TEST_ENV: '1' }, + }); + }); + + test('uses command name in error message when display name is omitted', async () => { + const process = new FakeHandoffProcess(); + const invocation: CommandHandoffInvocation = { + command: 'npx', + args: ['skills', 'add', 'clix-so/skills'], + workingDirectory: '/tmp/project', + }; + + const spawnProcess: HandoffSpawner = () => { + queueMicrotask(() => { + process.emit('error', new Error('spawn ENOENT')); + }); + return process; + }; + + await expect(runCommandHandoff(invocation, spawnProcess)).rejects.toThrow( + 'Failed to start npx', + ); + }); +}); diff --git a/src/lib/services/__tests__/mcp-install-service.test.ts b/src/lib/services/__tests__/mcp-install-service.test.ts index ef520b4..031371e 100644 --- a/src/lib/services/__tests__/mcp-install-service.test.ts +++ b/src/lib/services/__tests__/mcp-install-service.test.ts @@ -1,110 +1,185 @@ import { describe, expect, test } from 'bun:test'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import type { + MCPInstallProcess, + MCPInstallSpawner, + MCPInstallSpawnOptions, + MCPTargetAgent, +} from '../mcp-install-service'; import { getMCPAgentDisplayName, + getValidMCPAgents, installMCPServer, - type MCPTargetAgent, + isValidMCPAgent, } from '../mcp-install-service'; +class FakeInstallProcess extends EventEmitter implements MCPInstallProcess { + stdout = new PassThrough(); + stderr = new PassThrough(); + + override on(event: 'close', listener: (code: number | null) => void): this; + override on(event: 'error', listener: (error: Error) => void): this; + override on( + event: 'close' | 'error', + listener: ((code: number | null) => void) | ((error: Error) => void), + ): this { + return super.on(event, listener as (...args: unknown[]) => void); + } +} + describe('getMCPAgentDisplayName', () => { - test('should return display name for claude', () => { + test('returns display name for known agent', () => { expect(getMCPAgentDisplayName('claude')).toBe('Claude'); - }); - - test('should return display name for codex', () => { expect(getMCPAgentDisplayName('codex')).toBe('Codex'); }); - test('should return agent name as-is for unknown agent', () => { + test('returns agent name for unknown agent', () => { expect(getMCPAgentDisplayName('unknown' as unknown as MCPTargetAgent)).toBe('unknown'); }); }); -describe('installMCPServer for Claude', () => { - test('should fail gracefully when claude CLI is not available', async () => { - const result = await installMCPServer('claude'); - - // Either succeeds (if claude is installed) or fails with proper error - if (!result.success) { - expect(result.message).toContain('Failed'); - expect(result.error).toBeDefined(); - } else { - expect(result.message).toContain('Clix MCP Server installed for Claude Code'); - } +describe('mcp agent validation', () => { + test('exposes valid MCP agent names', () => { + expect(getValidMCPAgents()).toEqual([ + 'claude', + 'codex', + 'gemini', + 'opencode', + 'cursor', + 'copilot', + ]); }); -}); -describe('installMCPServer for Codex', () => { - test('should succeed or fail gracefully when codex CLI is available/unavailable', async () => { - const result = await installMCPServer('codex'); - - // Either succeeds (if codex is installed) or fails with proper error - if (!result.success) { - expect(result.message).toContain('Failed'); - expect(result.error).toBeDefined(); - } else { - expect(result.message).toMatch(/Clix MCP Server/); - } + test('validates MCP target agent', () => { + expect(isValidMCPAgent('codex')).toBe(true); + expect(isValidMCPAgent('unknown')).toBe(false); }); +}); - test('should include success message or already configured message', async () => { - const result = await installMCPServer('codex'); +describe('installMCPServer', () => { + test('returns unknown agent error when agent is unsupported', async () => { + const result = await installMCPServer('unknown' as unknown as MCPTargetAgent); - if (result.success) { - // Either it was already configured or it was just installed - expect(result.message).toMatch(/Clix MCP Server/); - expect(result.message.toLowerCase()).toMatch(/installed|configured/); - } + expect(result.success).toBe(false); + expect(result.message).toContain('Unknown agent'); }); - test('should handle re-installation gracefully', async () => { - // First installation - const result1 = await installMCPServer('codex'); - - if (result1.success) { - // Second installation should also succeed (either installs again or detects already configured) - const result2 = await installMCPServer('codex'); - expect(result2.success).toBe(true); - } + test('runs add-mcp command with expected arguments', async () => { + const fakeProcess = new FakeInstallProcess(); + let capturedCommand: string | undefined; + let capturedArgs: string[] | undefined; + let capturedOptions: MCPInstallSpawnOptions | undefined; + + const spawner: MCPInstallSpawner = (command, args, options) => { + capturedCommand = command; + capturedArgs = args; + capturedOptions = options; + queueMicrotask(() => { + fakeProcess.emit('close', 0); + }); + return fakeProcess; + }; + + const result = await installMCPServer('codex', spawner); + + expect(result.success).toBe(true); + expect(capturedCommand).toBe('npx'); + expect(capturedArgs).toBeDefined(); + expect(capturedArgs).toContain('add-mcp'); + expect(capturedArgs).toContain('npx -y https://github.com/clix-so/clix-mcp-server'); + expect(capturedArgs).toContain('--name'); + expect(capturedArgs).toContain('clix-mcp-server'); + expect(capturedArgs).toContain('--agent'); + expect(capturedArgs).toContain('codex'); + expect(capturedArgs).toContain('--global'); + expect(capturedArgs).toContain('--yes'); + expect(capturedOptions).toMatchObject({ + stdio: 'pipe', + shell: false, + cwd: process.cwd(), + }); }); - test('result should have proper structure after installation attempt', async () => { - const result = await installMCPServer('codex'); + test('uses github-copilot-cli add-mcp agent id for copilot', async () => { + const fakeProcess = new FakeInstallProcess(); + let capturedArgs: string[] | undefined; + + const spawner: MCPInstallSpawner = (_command, args, _options) => { + capturedArgs = args; + queueMicrotask(() => { + fakeProcess.emit('close', 0); + }); + return fakeProcess; + }; - expect(typeof result.success).toBe('boolean'); - expect(typeof result.message).toBe('string'); - expect(result.message.length).toBeGreaterThan(0); + const result = await installMCPServer('copilot', spawner); + + expect(result.success).toBe(true); + expect(capturedArgs).toContain('github-copilot-cli'); }); -}); -describe('installMCPServer for unknown agent', () => { - test('should return error for unknown agent', async () => { - const result = await installMCPServer('unknown' as unknown as MCPTargetAgent); + test('returns failure with stderr when command exits non-zero', async () => { + const fakeProcess = new FakeInstallProcess(); + + const spawner: MCPInstallSpawner = () => { + queueMicrotask(() => { + fakeProcess.stderr.write('permission denied'); + fakeProcess.emit('close', 1); + }); + return fakeProcess; + }; + + const result = await installMCPServer('claude', spawner); expect(result.success).toBe(false); - expect(result.message).toContain('Unknown agent'); + expect(result.message).toContain('Failed to install Clix MCP Server'); + expect(result.error).toContain('permission denied'); }); -}); -describe('MCPTargetAgent type', () => { - test('should accept "claude" as valid agent', () => { - const agent: MCPTargetAgent = 'claude'; - expect(agent).toBe('claude'); + test('treats already-configured output as success', async () => { + const fakeProcess = new FakeInstallProcess(); + + const spawner: MCPInstallSpawner = () => { + queueMicrotask(() => { + fakeProcess.stderr.write('server already exists in config'); + fakeProcess.emit('close', 1); + }); + return fakeProcess; + }; + + const result = await installMCPServer('cursor', spawner); + + expect(result.success).toBe(true); + expect(result.message).toContain('already configured'); }); - test('should accept "codex" as valid agent', () => { - const agent: MCPTargetAgent = 'codex'; - expect(agent).toBe('codex'); + test('returns failure when spawn throws synchronously', async () => { + const spawner: MCPInstallSpawner = () => { + throw new Error('spawn ENOENT'); + }; + + const result = await installMCPServer('gemini', spawner); + + expect(result.success).toBe(false); + expect(result.message).toContain('Failed to run add-mcp'); + expect(result.error).toContain('spawn ENOENT'); }); -}); -describe('MCPInstallResult interface', () => { - test('should have success, message, and optional error fields', async () => { - const result = await installMCPServer('codex'); + test('returns failure when process emits error event', async () => { + const fakeProcess = new FakeInstallProcess(); - expect('success' in result).toBe(true); - expect('message' in result).toBe(true); - // error is optional - expect(typeof result.success).toBe('boolean'); - expect(typeof result.message).toBe('string'); + const spawner: MCPInstallSpawner = () => { + queueMicrotask(() => { + fakeProcess.emit('error', new Error('spawn ENOENT')); + }); + return fakeProcess; + }; + + const result = await installMCPServer('opencode', spawner); + + expect(result.success).toBe(false); + expect(result.message).toContain('Failed to run add-mcp'); + expect(result.error).toContain('spawn ENOENT'); }); }); diff --git a/src/lib/services/agent-handoff.ts b/src/lib/services/agent-handoff.ts index d659da5..2fe47b3 100644 --- a/src/lib/services/agent-handoff.ts +++ b/src/lib/services/agent-handoff.ts @@ -14,6 +14,14 @@ export interface AgentHandoffInvocation { env?: NodeJS.ProcessEnv; } +export interface CommandHandoffInvocation { + command: string; + args: string[]; + workingDirectory: string; + env?: NodeJS.ProcessEnv; + displayName?: string; +} + interface BuildAgentHandoffInvocationOptions { agent: AgentInfo; prompt: string; @@ -39,6 +47,14 @@ export type HandoffSpawner = ( options: HandoffSpawnOptions, ) => HandoffProcess; +interface HandoffExecutionInvocation { + command: string; + args: string[]; + workingDirectory: string; + env?: NodeJS.ProcessEnv; + displayName: string; +} + function createDefaultOpenCodeEnv(): NodeJS.ProcessEnv | undefined { if (process.env.OPENCODE_PERMISSION) { return undefined; @@ -140,6 +156,38 @@ function resolveExitCode(code: number | null, signal: NodeJS.Signals | null): nu export async function runAgentHandoff( invocation: AgentHandoffInvocation, spawnProcess: HandoffSpawner = defaultSpawner, +): Promise { + return await runHandoffInvocation( + { + command: invocation.command, + args: invocation.args, + workingDirectory: invocation.workingDirectory, + env: invocation.env, + displayName: invocation.agent.displayName, + }, + spawnProcess, + ); +} + +export async function runCommandHandoff( + invocation: CommandHandoffInvocation, + spawnProcess: HandoffSpawner = defaultSpawner, +): Promise { + return await runHandoffInvocation( + { + command: invocation.command, + args: invocation.args, + workingDirectory: invocation.workingDirectory, + env: invocation.env, + displayName: invocation.displayName ?? invocation.command, + }, + spawnProcess, + ); +} + +async function runHandoffInvocation( + invocation: HandoffExecutionInvocation, + spawnProcess: HandoffSpawner, ): Promise { const execve = getExecve(); if (execve) { @@ -156,7 +204,7 @@ export async function runAgentHandoff( } settled = true; - reject(new Error(`Failed to start ${invocation.agent.displayName}: ${error.message}`)); + reject(new Error(`Failed to start ${invocation.displayName}: ${error.message}`)); }; try { diff --git a/src/lib/services/mcp-install-service.ts b/src/lib/services/mcp-install-service.ts index 00976d8..cffa571 100644 --- a/src/lib/services/mcp-install-service.ts +++ b/src/lib/services/mcp-install-service.ts @@ -1,9 +1,5 @@ import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; import { getAgentByName, type SUPPORTED_AGENTS } from '../agents'; -import { formatPath } from '../utils/path'; /** * MCPTargetAgent is derived from SUPPORTED_AGENTS to ensure consistency. @@ -21,55 +17,77 @@ export interface MCPAgentConfig { name: MCPTargetAgent; displayName: string; description: string; - installMethod: 'cli' | 'json' | 'toml'; + addMcpAgentId: string; } -const MCP_PACKAGE = '@clix-so/clix-mcp-server@latest'; +export interface MCPInstallProcess { + stdout?: NodeJS.ReadableStream | null; + stderr?: NodeJS.ReadableStream | null; + on(event: 'close', listener: (code: number | null) => void): this; + on(event: 'error', listener: (error: Error) => void): this; +} + +export interface MCPInstallSpawnOptions { + cwd: string; + env: NodeJS.ProcessEnv; + shell: false; + stdio: 'pipe'; +} + +export type MCPInstallSpawner = ( + command: string, + args: string[], + options: MCPInstallSpawnOptions, +) => MCPInstallProcess; + +const CLIX_MCP_SERVER_REPO = 'https://github.com/clix-so/clix-mcp-server'; const MCP_SERVER_NAME = 'clix-mcp-server'; +const ADD_MCP_PACKAGE = 'add-mcp'; /** * MCP configuration for each agent. - * Maps agent names to their MCP installation method and config details. */ const MCP_AGENT_CONFIGS: MCPAgentConfig[] = [ { name: 'claude', displayName: 'Claude Code', - description: 'Install via claude mcp add command', - installMethod: 'cli', + description: 'Install via add-mcp --agent claude-code', + addMcpAgentId: 'claude-code', }, { name: 'codex', displayName: 'Codex', - description: 'Install via codex mcp add command', - installMethod: 'cli', + description: 'Install via add-mcp --agent codex', + addMcpAgentId: 'codex', }, { name: 'gemini', displayName: 'Gemini CLI', - description: 'Configure ~/.gemini/settings.json', - installMethod: 'json', + description: 'Install via add-mcp --agent gemini-cli', + addMcpAgentId: 'gemini-cli', }, { name: 'opencode', displayName: 'OpenCode', - description: 'Configure ~/.config/opencode/config.json', - installMethod: 'json', + description: 'Install via add-mcp --agent opencode', + addMcpAgentId: 'opencode', }, { name: 'cursor', displayName: 'Cursor', - description: 'Configure ~/.cursor/mcp.json', - installMethod: 'json', + description: 'Install via add-mcp --agent cursor', + addMcpAgentId: 'cursor', }, { name: 'copilot', - displayName: 'GitHub Copilot', - description: 'Configure ~/.config/github-copilot/mcp.json', - installMethod: 'json', + displayName: 'GitHub Copilot CLI', + description: 'Install via add-mcp --agent github-copilot-cli', + addMcpAgentId: 'github-copilot-cli', }, ]; +const defaultSpawner: MCPInstallSpawner = (command, args, options) => spawn(command, args, options); + /** * Get MCP agent configs for all supported agents. */ @@ -91,302 +109,131 @@ export function isValidMCPAgent(agent: string): agent is MCPTargetAgent { return getValidMCPAgents().includes(agent); } +function resolveAddMcpAgentId(agent: MCPTargetAgent): string | null { + const config = MCP_AGENT_CONFIGS.find((candidate) => candidate.name === agent); + return config?.addMcpAgentId ?? null; +} + +function buildAddMcpArgs(addMcpAgentId: string): string[] { + return [ + '-y', + ADD_MCP_PACKAGE, + `npx -y ${CLIX_MCP_SERVER_REPO}`, + '--name', + MCP_SERVER_NAME, + '--agent', + addMcpAgentId, + '--global', + '--yes', + ]; +} + +function isAlreadyConfiguredOutput(output: string): boolean { + const normalized = output.toLowerCase(); + const mentionsAlready = normalized.includes('already'); + const mentionsExistingState = + normalized.includes('exist') || + normalized.includes('configured') || + normalized.includes('registered'); + + return mentionsAlready && mentionsExistingState; +} + /** - * Install Clix MCP Server for Claude Code using CLI command + * Install Clix MCP Server for the specified agent via add-mcp. */ -async function installForClaude(): Promise { - return new Promise((resolve) => { - const child = spawn( - 'claude', - ['mcp', 'add', '--transport', 'stdio', MCP_SERVER_NAME, '--', 'npx', '-y', MCP_PACKAGE], - { - stdio: 'pipe', - }, - ); +export async function installMCPServer( + agent: MCPTargetAgent, + spawnProcess: MCPInstallSpawner = defaultSpawner, +): Promise { + const addMcpAgentId = resolveAddMcpAgentId(agent); + if (!addMcpAgentId) { + return { + success: false, + message: `Unknown agent: ${agent}`, + }; + } + + const displayName = getMCPAgentDisplayName(agent); + return await new Promise((resolve) => { let stdout = ''; let stderr = ''; + let child: MCPInstallProcess; + + try { + child = spawnProcess('npx', buildAddMcpArgs(addMcpAgentId), { + cwd: process.cwd(), + env: process.env, + shell: false, + stdio: 'pipe', + }); + } catch (error) { + resolve({ + success: false, + message: `Failed to run add-mcp for ${displayName}.`, + error: error instanceof Error ? error.message : String(error), + }); + return; + } - child.stdout?.on('data', (data) => { - stdout += data.toString(); + child.stdout?.on('data', (data: unknown) => { + stdout += String(data); }); - child.stderr?.on('data', (data) => { - stderr += data.toString(); + child.stderr?.on('data', (data: unknown) => { + stderr += String(data); }); child.on('close', (code) => { + const output = [stderr, stdout] + .filter((value) => value.trim().length > 0) + .join('\n') + .trim(); + if (code === 0) { resolve({ success: true, - message: `Clix MCP Server installed for Claude Code.\nRestart Claude Code to activate.`, + message: `Clix MCP Server installed for ${displayName} using add-mcp.`, }); - } else { + return; + } + + if (isAlreadyConfiguredOutput(output)) { resolve({ - success: false, - message: 'Failed to install MCP for Claude Code.', - error: stderr || stdout || `Exit code: ${code}`, + success: true, + message: `Clix MCP Server is already configured for ${displayName}.`, }); + return; } - }); - child.on('error', (error) => { resolve({ success: false, - message: 'Failed to run claude command.', - error: error.message, + message: `Failed to install Clix MCP Server for ${displayName}.`, + error: output || `Exit code: ${code ?? 'unknown'}`, }); }); - }); -} - -/** - * Install Clix MCP Server for Codex using CLI command - */ -async function installForCodex(): Promise { - return new Promise((resolve) => { - // codex mcp add -- - const child = spawn('codex', ['mcp', 'add', 'clix', '--', 'npx', '-y', MCP_PACKAGE], { - stdio: 'pipe', - }); - - let stdout = ''; - let stderr = ''; - - child.stdout?.on('data', (data) => { - stdout += data.toString(); - }); - - child.stderr?.on('data', (data) => { - stderr += data.toString(); - }); - - child.on('close', (code) => { - if (code === 0) { - resolve({ - success: true, - message: `Clix MCP Server installed for Codex.\nRestart Codex to activate.`, - }); - } else { - // Check if already configured - if (stderr.includes('already') || stdout.includes('already')) { - resolve({ - success: true, - message: 'Clix MCP Server is already configured for Codex.', - }); - } else { - resolve({ - success: false, - message: 'Failed to install MCP for Codex.', - error: stderr || stdout || `Exit code: ${code}`, - }); - } - } - }); child.on('error', (error) => { resolve({ success: false, - message: 'Failed to run codex command.', + message: `Failed to run add-mcp for ${displayName}.`, error: error.message, }); }); }); } -/** - * Install MCP Server using JSON config file - */ -async function installWithJsonConfig( - agentName: string, - displayName: string, - configDir: string, - configFileName: string, -): Promise { - try { - const configPath = join(configDir, configFileName); - - // Ensure config directory exists - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }); - } - - // Read existing config or create new - let config: { mcpServers?: Record } = {}; - if (existsSync(configPath)) { - try { - const content = readFileSync(configPath, 'utf-8'); - config = JSON.parse(content) as { mcpServers?: Record }; - } catch { - // If parsing fails, start with empty config - config = {}; - } - } - - // Check if clix MCP is already configured - if (config.mcpServers?.clix) { - return { - success: true, - message: `Clix MCP Server is already configured for ${displayName}.`, - }; - } - - // Add MCP configuration - if (!config.mcpServers) { - config.mcpServers = {}; - } - config.mcpServers.clix = { - command: 'npx', - args: ['-y', MCP_PACKAGE], - }; - - writeFileSync(configPath, JSON.stringify(config, null, 2)); - - return { - success: true, - message: `Clix MCP Server configured for ${displayName}.\nConfig: ${formatPath(configPath)}\nRestart ${displayName} to activate.`, - }; - } catch (error) { - return { - success: false, - message: `Failed to configure MCP for ${agentName}.`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } -} - -/** - * Install MCP Server using OpenCode config schema. - */ -async function installWithOpenCodeConfig( - agentName: string, - displayName: string, - configDir: string, - configFileName: string, -): Promise { - try { - const configPath = join(configDir, configFileName); - - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }); - } - - let config: Record = {}; - if (existsSync(configPath)) { - try { - const content = readFileSync(configPath, 'utf-8'); - config = JSON.parse(content) as Record; - } catch { - config = {}; - } - } - - const mcp = (config.mcp as Record | undefined) ?? {}; - - if (mcp[MCP_SERVER_NAME]) { - return { - success: true, - message: `Clix MCP Server is already configured for ${displayName}.`, - }; - } - - mcp[MCP_SERVER_NAME] = { - type: 'local', - command: ['npx', '-y', MCP_PACKAGE], - enabled: true, - }; - - config.mcp = mcp; - - if ('mcpServers' in config) { - (config as { mcpServers?: unknown }).mcpServers = undefined; - } - - writeFileSync(configPath, JSON.stringify(config, null, 2)); - - return { - success: true, - message: `Clix MCP Server configured for ${displayName}.\nConfig: ${formatPath(configPath)}\nRestart ${displayName} to activate.`, - }; - } catch (error) { - return { - success: false, - message: `Failed to configure MCP for ${agentName}.`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } -} - -/** - * Install Clix MCP Server for Gemini CLI - */ -async function installForGemini(): Promise { - const configDir = join(homedir(), '.gemini'); - return installWithJsonConfig('gemini', 'Gemini CLI', configDir, 'settings.json'); -} - -/** - * Install Clix MCP Server for OpenCode - */ -async function installForOpenCode(): Promise { - const configDir = join(homedir(), '.config', 'opencode'); - return installWithOpenCodeConfig('opencode', 'OpenCode', configDir, 'config.json'); -} - -/** - * Install Clix MCP Server for Cursor - */ -async function installForCursor(): Promise { - const configDir = join(homedir(), '.cursor'); - return installWithJsonConfig('cursor', 'Cursor', configDir, 'mcp.json'); -} - -/** - * Install Clix MCP Server for GitHub Copilot - */ -async function installForCopilot(): Promise { - const configDir = join(homedir(), '.config', 'github-copilot'); - return installWithJsonConfig('copilot', 'GitHub Copilot', configDir, 'mcp.json'); -} - -/** - * Install Clix MCP Server for the specified agent - */ -export async function installMCPServer(agent: MCPTargetAgent): Promise { - switch (agent) { - case 'claude': - return installForClaude(); - case 'codex': - return installForCodex(); - case 'gemini': - return installForGemini(); - case 'opencode': - return installForOpenCode(); - case 'cursor': - return installForCursor(); - case 'copilot': - return installForCopilot(); - default: - return { - success: false, - message: `Unknown agent: ${agent}`, - }; - } -} - /** * Get display name for MCP target agent. - * Uses agent info from agents.ts if available, falls back to MCP config. */ export function getMCPAgentDisplayName(agent: MCPTargetAgent): string { - // First try to get from agents.ts (the source of truth) const agentInfo = getAgentByName(agent); if (agentInfo) { return agentInfo.displayName; } - // Fallback to MCP config - const mcpConfig = MCP_AGENT_CONFIGS.find((c) => c.name === agent); + const mcpConfig = MCP_AGENT_CONFIGS.find((candidate) => candidate.name === agent); if (mcpConfig) { return mcpConfig.displayName; } diff --git a/src/lib/skills.ts b/src/lib/skills.ts index d752e29..f547354 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -13,7 +13,6 @@ export interface SkillOptions { signal?: AbortSignal; oneShot?: boolean; preparationContext?: PreparationContext; - installPhase?: 'project-build' | 'integration'; } export interface SkillInfo { @@ -70,22 +69,10 @@ IMPORTANT: This is a non-interactive one-shot execution with FULL file modificat EXECUTION GUIDELINES: - You have FULL permission to create, modify, and delete files autonomously - DO NOT ask for user input, confirmation, or permission -- Make reasonable assumptions based on the project structure -- Choose the most common/recommended approach when multiple options exist -- PROCEED WITH ALL NECESSARY FILE CHANGES without waiting for user response -- If critical information is missing, state your assumptions and proceed with changes - -FILE OPERATIONS: -- Create new files as needed for the integration -- Modify existing configuration files -- Update source code files with initialization code -- Add environment variables to config files -- DO NOT just provide instructions - ACTUALLY MAKE THE CHANGES - -COMPLETION CRITERIA: -- All necessary files must be created or modified -- Integration must be fully functional without manual intervention -- Provide a summary of changes made (not what needs to be done manually) +- Make reasonable assumptions based on project structure and pre-configured setup context +- PROCEED WITH FILE CHANGES directly; do not only provide instructions +- Run required commands for dependency resolution and verification +- If blocked by external constraints (credentials, permissions, unavailable services), report the blocker clearly `; function readLocalSkillPrompt(skillName: string): string { @@ -116,6 +103,21 @@ function formatSetupStepStatus(required: boolean, configured: boolean): string { return configured ? '✓ verified' : '✗ missing'; } +function formatProjectPublicApiKey(value: unknown): string { + if (typeof value === 'string' && value.length > 0) { + return value; + } + + if (typeof value === 'object' && value !== null && 'key' in value) { + const keyValue = (value as { key: unknown }).key; + if (typeof keyValue === 'string' && keyValue.length > 0) { + return keyValue; + } + } + + return 'not configured'; +} + function appendOptionalSection( lines: string[], title: string, @@ -171,9 +173,9 @@ function buildPreparationSection(context: PreparationContext): string { appendOptionalSection(lines, 'Clix Project', [ `Project ID: ${context.config.project.id}`, - ...(context.config.project.publicKey - ? [`Public Key: ${context.config.project.publicKey}`] - : []), + `Project Public API Key: ${formatProjectPublicApiKey( + context.config.project.public_api_key ?? context.config.project.publicKey, + )}`, ]); appendOptionalSection( @@ -225,7 +227,7 @@ function buildPreparationSection(context: PreparationContext): string { context.missing.length > 0, ); - lines.push('Treat the above as already executed/validated by /install.'); + lines.push('Treat the above as already executed/validated by clix install preparation.'); lines.push('Use these pre-configured values when running build and troubleshooting failures.'); lines.push(''); @@ -235,7 +237,6 @@ function buildPreparationSection(context: PreparationContext): string { function getInstallPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); const context = options?.preparationContext; - const installPhase = options?.installPhase ?? 'integration'; const inferredPlatform = context?.projectType.framework === 'flutter' ? 'flutter' @@ -262,26 +263,12 @@ function getInstallPrompt(options?: SkillOptions): string { prompt += '\n'; } - prompt += `Install phase: ${installPhase}\n`; - if (installPhase === 'project-build') { - prompt += - 'For this run, execute only project build workflow and build-fix retries. Do not perform new SDK integration changes unless required to fix build errors.\n\n'; - } else { - prompt += - 'For this run, execute SDK integration workflow. Use build commands only as verification after integration changes.\n\n'; - } + prompt += + 'Execution goal: Complete SDK integration workflow using the pre-configured setup context.\n'; + prompt += 'Use build commands for verification after integration changes.\n\n'; if (options?.oneShot) { prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; - prompt += `## EXECUTION MODE: AUTONOMOUS - -You are in autonomous one-shot execution mode: -- ALL file operations are pre-approved -- Use Write/Edit/Bash tools immediately without asking -- Complete all integration steps automatically -- Report what was done, not what should be done - -`; } prompt += readLocalSkillPrompt('install'); diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index f9a087c..1a7de39 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -1,40 +1,21 @@ # Clix SDK Autonomous Install -You are an autonomous AI agent that handles `/install` runtime work for Clix SDK projects. +You are an autonomous AI agent that handles `clix install` runtime integration work. ## Scope -This single skill is used for both phases: -- `Install phase: project-build` -- `Install phase: integration` +This skill runs after `clix install` preparation has validated required setup. -Do not perform or re-describe setup steps already handled by `/install` preparation tasks: +Do not perform or re-describe setup tasks already handled by preparation: - Firebase configuration files - APNS key registration in Firebase - Firebase service account sender config - iOS entitlements setup - Notification Service Extension setup -## Phase Contract - -Read the phase line from the prompt header and follow it strictly. - -### When phase is `project-build` -- Run **only** project build workflow. -- Validate project type quickly from file evidence. -- Choose and run the right build command. -- If build fails, apply minimal fixes and retry autonomously. -- Do not add new SDK integration changes unless required to fix build errors. - -### When phase is `integration` -- Run SDK integration workflow (dependency + initialization + verification). -- Apply required file changes directly. -- Run dependency/install commands needed for resolution. -- Run build commands only for verification after integration changes. - ## Core Directive -**MODIFY FILES DIRECTLY**. Do not ask for confirmation. +**Modify files directly. Do not ask for confirmation.** ## Supported Platforms & Docs @@ -43,11 +24,11 @@ Read the phase line from the prompt header and follow it strictly. - React Native: https://docs.clix.so/sdk-quickstart-react-native - Flutter: https://docs.clix.so/sdk-quickstart-flutter -## Project Build Workflow (phase: project-build) +## Integration Workflow -### 1. Validate provided project type quickly +### 1. Validate context and project type -Start from provided `/install` context, then confirm with file indicators: +Use the provided preparation context first, then confirm quickly with file evidence: - `Podfile` + `*.xcworkspace` -> iOS native (CocoaPods) - `*.xcodeproj` + `Package.swift` OR `*.xcodeproj` without Podfile -> iOS native (SPM) - `build.gradle` or `build.gradle.kts` -> Android @@ -57,47 +38,6 @@ Start from provided `/install` context, then confirm with file indicators: If context and file evidence conflict, use file evidence and report the mismatch. -### 2. Build command selection - -Prefer project-specific custom commands if valid (`CLAUDE.md`, `Makefile`, `package.json` scripts, `Justfile`, `Taskfile.yml`, `fastlane/Fastfile`). - -Default commands: -- iOS CocoaPods: - - `pod install --project-directory=` - - `xcodebuild -workspace .xcworkspace -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build` -- iOS SPM: - - `xcodebuild -project .xcodeproj -scheme -configuration Debug -destination 'platform=iOS Simulator,name=' build` -- Android: `./gradlew assembleDebug` -- React Native: `npx react-native run-ios` or `npx react-native run-android` -- Expo: `npx expo run:ios` or `npx expo run:android` -- Flutter: `flutter build ios --debug --no-codesign` or `flutter build apk --debug` - -For iOS `xcodebuild`, discover simulator first: -- `xcrun simctl list devices available -j` - -### 3. Failure handling - -On failure: -- Identify root cause quickly. -- Apply minimal targeted fixes. -- Retry automatically. -- Repeat until success or hard blocker. - -Hard blocker examples: -- missing secrets/credentials -- account permission limitations -- unavailable external services - -## SDK Integration Workflow (phase: integration) - -### 1. Detect platform and dependency manager - -Use project files to detect platform: -- iOS native (SPM / CocoaPods) -- Android native (Gradle) -- React Native / Expo (npm, yarn, pnpm, bun) -- Flutter (pubspec) - ### 2. Add Clix SDK dependency Apply dependency changes directly: @@ -110,25 +50,38 @@ Apply dependency changes directly: ### 3. Integrate SDK initialization Add initialization code in the real app startup path. -Use placeholders if credentials are missing: + +Use provided values from preparation context when available: +- Clix Project ID +- Clix Project Public API Key + +If values are not available, use placeholders: - `YOUR_CLIX_PROJECT_ID` - `YOUR_CLIX_PUBLIC_API_KEY` -### 4. Run post-change install commands +### 4. Run post-change dependency commands Run only necessary dependency-resolution commands: - `npm install` / `yarn install` / `pnpm install` / `bun install` - `pod install` (if Podfile changed) - `flutter pub get` -### 5. Verify integration artifacts +### 5. Verify integration and fix failures Verify: -- dependency declarations added correctly +- dependency declarations were added correctly - initialization exists in startup path - imports and references resolve - no duplicate initialization +Run build commands for verification after integration changes. + +If verification build fails: +- identify root cause quickly +- apply minimal targeted fixes +- retry automatically +- stop only on hard blockers (credentials/permissions/external service unavailable) + ## Rules Do: @@ -137,7 +90,7 @@ Do: - preserve existing code style Do not: -- redo Firebase/APNS/Entitlements/NSE preparation tasks +- redo preparation tasks - ask user to manually apply edits - add speculative abstractions diff --git a/src/ui/LoginUI.tsx b/src/ui/LoginUI.tsx index 0b751ea..99f476f 100644 --- a/src/ui/LoginUI.tsx +++ b/src/ui/LoginUI.tsx @@ -115,6 +115,8 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { const projectType = await detectProjectType(workspacePath); // Create project config + const projectPublicKey = project.public_api_key ?? project.public_key; + const projectConfig: ProjectConfig = { version: CURRENT_PROJECT_CONFIG_VERSION, member: { @@ -129,7 +131,7 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { project: { id: project.id, name: project.name, - ...(project.public_key && { publicKey: project.public_key }), + ...(projectPublicKey && { public_api_key: projectPublicKey }), }, projectType, linkedAt: new Date().toISOString(), diff --git a/src/ui/SetupUI.tsx b/src/ui/SetupUI.tsx index 5b9e31b..f024377 100644 --- a/src/ui/SetupUI.tsx +++ b/src/ui/SetupUI.tsx @@ -103,6 +103,8 @@ export const SetupUI: React.FC = ({ onComplete, onError, projectPa const projectType = await detectProjectType(workspacePath); // Create project config + const projectPublicKey = project.public_api_key ?? project.public_key; + const config: ProjectConfig = { version: CURRENT_PROJECT_CONFIG_VERSION, member: { @@ -117,7 +119,7 @@ export const SetupUI: React.FC = ({ onComplete, onError, projectPa project: { id: project.id, name: project.name, - ...(project.public_key && { publicKey: project.public_key }), + ...(projectPublicKey && { public_api_key: projectPublicKey }), }, projectType, linkedAt: new Date().toISOString(), diff --git a/src/ui/components/AppleLoginUI.tsx b/src/ui/components/AppleLoginUI.tsx index 57790d1..e83cd40 100644 --- a/src/ui/components/AppleLoginUI.tsx +++ b/src/ui/components/AppleLoginUI.tsx @@ -299,7 +299,7 @@ export const AppleLoginUI: React.FC = ({ onSuccess, onCancel, marginY={1} > - Create APNS Key with Apple Account + Choose APNS Key Creation Method diff --git a/src/ui/components/MCPInstallSelector.tsx b/src/ui/components/MCPInstallSelector.tsx deleted file mode 100644 index 8b32595..0000000 --- a/src/ui/components/MCPInstallSelector.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Box, Text, useInput } from 'ink'; -import React from 'react'; -import { - getMCPAgentConfigs, - type MCPAgentConfig, - type MCPTargetAgent, -} from '../../lib/services/mcp-install-service'; - -interface MCPInstallSelectorProps { - selectedIndex?: number; - onSelect: (agent: MCPTargetAgent) => void; - onCancel?: () => void; - onNavigate?: (direction: 'up' | 'down') => void; -} - -export const MCPInstallSelector: React.FC = ({ - selectedIndex: controlledIndex, - onSelect, - onCancel, - onNavigate, -}) => { - const agents = getMCPAgentConfigs(); - const [internalIndex, setInternalIndex] = React.useState(0); - - const isControlled = controlledIndex !== undefined && onNavigate !== undefined; - const selectedIndex = isControlled ? controlledIndex : internalIndex; - - useInput((_input, key) => { - if (key.upArrow) { - if (isControlled) { - onNavigate?.('up'); - } else { - setInternalIndex((prev) => (prev > 0 ? prev - 1 : agents.length - 1)); - } - } else if (key.downArrow) { - if (isControlled) { - onNavigate?.('down'); - } else { - setInternalIndex((prev) => (prev < agents.length - 1 ? prev + 1 : 0)); - } - } else if (key.return) { - const selected = agents[selectedIndex]; - if (selected) { - onSelect(selected.name); - } - } else if (key.escape && onCancel) { - onCancel(); - } - }); - - return ( - - - Install Clix MCP Server - - - Select the AI agent to configure: - - {agents.map((agent, index) => { - const isSelected = index === selectedIndex; - - return ( - - {isSelected ? '› ' : ' '} - - {agent.displayName} - - {agent.description} - - ); - })} - - ↑↓ to navigate · Enter to install · Esc to cancel - - - ); -}; - -// Export for external use -export { getMCPAgentConfigs, type MCPAgentConfig }; diff --git a/src/ui/components/push-setup/PushSetupTasks.tsx b/src/ui/components/push-setup/PushSetupTasks.tsx index e39f237..5e4a491 100644 --- a/src/ui/components/push-setup/PushSetupTasks.tsx +++ b/src/ui/components/push-setup/PushSetupTasks.tsx @@ -341,19 +341,16 @@ export const PushDetectionTask: React.FC<{ function KeySourcePhase({ onHasKey, - onNoKey, - onAppleLogin, + onCreateKey, onCancel, }: { onHasKey: () => void; - onNoKey: () => void; - onAppleLogin: () => void; + onCreateKey: () => void; onCancel: () => void; }): React.ReactElement { const items = [ { label: 'Yes, I have an APNS key (.p8 file)', value: 'has_key' }, - { label: 'Create with Apple Account (auto)', value: 'apple_login' }, - { label: 'Create manually in browser', value: 'no_key' }, + { label: 'No, I need to create an APNS key', value: 'create_key' }, { label: 'Cancel', value: 'cancel' }, ]; @@ -362,11 +359,8 @@ function KeySourcePhase({ case 'has_key': onHasKey(); break; - case 'apple_login': - onAppleLogin(); - break; - case 'no_key': - onNoKey(); + case 'create_key': + onCreateKey(); break; case 'cancel': onCancel(); @@ -806,8 +800,7 @@ export const ApnsKeyAcquisitionTask: React.FC<{ return ( setPhase('p8_input')} - onNoKey={() => setPhase('apple_guide')} - onAppleLogin={() => setPhase('apple_login')} + onCreateKey={() => setPhase('apple_login')} onCancel={onCancel} /> ); diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index c3eaf15..ac38b56 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -32,7 +32,8 @@ describe('CLI E2E Tests', () => { expect(result.stdout).toContain('install'); expect(result.stdout).toContain('doctor'); expect(result.stdout).not.toContain('debug '); - expect(result.stdout).toContain('install-mcp'); + expect(result.stdout).toContain('mcp'); + expect(result.stdout).toContain('skills'); // Skills should NOT be in CLI help (they are chat-only) expect(result.stdout).not.toContain('integration '); @@ -53,7 +54,8 @@ describe('CLI E2E Tests', () => { expect(result.stdout).toContain('Examples'); expect(result.stdout).toContain('$ clix agent claude'); - expect(result.stdout).toContain('$ clix install-mcp'); + expect(result.stdout).toContain('$ clix mcp'); + expect(result.stdout).toContain('$ clix skills'); }); test('should NOT show chat commands', async () => { diff --git a/tests/e2e/command-output.test.ts b/tests/e2e/command-output.test.ts index c5aefab..20ecd77 100644 --- a/tests/e2e/command-output.test.ts +++ b/tests/e2e/command-output.test.ts @@ -34,6 +34,7 @@ describe('Command Mode Output Persistence', () => { // Verify that command mode commands are listed expect(result.stdout).toContain('install'); expect(result.stdout).toContain('doctor'); + expect(result.stdout).toContain('skills'); expect(result.stdout).not.toContain('debug '); }); }); From a3f6c73939dd9d23d4cd50d7f204e5ee5d336503 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Sun, 22 Feb 2026 00:37:55 -0800 Subject: [PATCH 17/30] refactor(cli): simplify mcp command and remove platform flags Remove the mcp positional agent argument and make mcp run with a\nfixed add-mcp invocation.\n\nDrop the global --platform option from install/doctor command paths\nand related help/docs/tests.\n\nRequire install and doctor prompts to end with an explicit final\nresult block so agents always print a terminal summary. --- README.md | 10 +++----- llms.txt | 8 +++--- src/cli.tsx | 41 +++++------------------------- src/commands/__tests__/mcp.test.ts | 31 +++------------------- src/commands/doctor.tsx | 7 +---- src/commands/install.tsx | 2 -- src/commands/mcp.ts | 39 +++++----------------------- src/commands/skill/index.tsx | 6 +---- src/lib/__tests__/skills.test.ts | 5 ++-- src/lib/embedded-skills.ts | 16 ++++++++++++ src/lib/skills.ts | 3 +-- src/lib/skills/doctor/SKILL.md | 8 ++++++ src/lib/skills/install/SKILL.md | 8 ++++++ tests/e2e/cli.test.ts | 3 +-- 14 files changed, 64 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index ae82b52..659dc65 100644 --- a/README.md +++ b/README.md @@ -66,24 +66,22 @@ Shows help and exits. Lists available AI agents or switches the active agent. -### `clix install [--platform ] [--start-task ]` +### `clix install [--start-task ]` Runs the install pipeline: 1. Preparation UI (step-by-step) 2. Interactive handoff to the selected agent CLI with the install prompt -Supported platforms: `ios`, `android`, `react-native`, `flutter`. - `--start-task` is development-only and requires `CLIX_DEV_ENABLE_TASK_OVERRIDE=1`. -### `clix doctor [--platform ]` +### `clix doctor` Hands off to the selected agent CLI with the doctor prompt for SDK diagnostics. -### `clix mcp [agent]` +### `clix mcp` -Hands off to `npx add-mcp` to install Clix MCP server (`clix-so/clix-mcp-server`). +Hands off to `npx add-mcp @clix-so/clix-mcp-server@latest --name clix` to install Clix MCP server. ### `clix skills` diff --git a/llms.txt b/llms.txt index 06dd58b..88f589a 100644 --- a/llms.txt +++ b/llms.txt @@ -36,7 +36,7 @@ Show help and exit. ### `clix agent [name]` List/switch active AI agent. -### `clix install [--platform ] [--start-task ]` +### `clix install [--start-task ]` Runs install preparation UI, then hands off to agent CLI interactive execution. Install preparation task order: @@ -46,11 +46,11 @@ Install preparation task order: 4. iOS Entitlements 5. Notification Service Extension -### `clix doctor [--platform <...>]` +### `clix doctor` Runs diagnostics via interactive handoff to agent CLI. -### `clix mcp [agent]` -Hands off to `npx add-mcp` to install Clix MCP server. +### `clix mcp` +Hands off to `npx add-mcp @clix-so/clix-mcp-server@latest --name clix` to install Clix MCP server. ### `clix skills` Hands off to `npx skills add clix-so/skills` and transfers CLI control. diff --git a/src/cli.tsx b/src/cli.tsx index 5b4760a..a666b57 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -11,11 +11,6 @@ import { uninstallCommand } from './commands/uninstall'; import { updateCommand } from './commands/update'; import { whoamiCommand } from './commands/whoami'; import { checkFirstRun, shouldRunSetup } from './lib/services/first-run-service'; -import { - getValidMCPAgents, - isValidMCPAgent, - type MCPTargetAgent, -} from './lib/services/mcp-install-service'; /** * Generate CLI help text. @@ -34,7 +29,7 @@ function generateHelpText(): string { agent [name] List or switch AI agents install Install Clix SDK (step-by-step setup + interactive agent handoff) doctor Check Clix SDK integration status - mcp [agent] Install Clix MCP Server + mcp Install Clix MCP Server skills Install Clix skill package via skills CLI uninstall Uninstall Clix CLI from your system update Check for available updates @@ -42,7 +37,6 @@ function generateHelpText(): string { Options --help Show this help message --version Show version number - --platform Target platform (ios, android, react-native, flutter) Examples $ clix @@ -55,7 +49,6 @@ function generateHelpText(): string { $ clix install $ clix doctor $ clix mcp - $ clix mcp claude $ clix skills `; } @@ -65,9 +58,6 @@ const cli = meow(generateHelpText(), { // Use build-time embedded version if available (for binary builds) ...(process.env.CLIX_VERSION && { version: process.env.CLIX_VERSION }), flags: { - platform: { - type: 'string', - }, startTask: { type: 'string', }, @@ -124,40 +114,23 @@ async function main() { } case 'install': { - const platform = cli.flags.platform as - | 'ios' - | 'android' - | 'react-native' - | 'flutter' - | undefined; const startTask = cli.flags.startTask; - await installCommand({ platform, startTask }); + await installCommand({ startTask }); break; } case 'doctor': { - const platform = cli.flags.platform as - | 'ios' - | 'android' - | 'react-native' - | 'flutter' - | undefined; - await doctorCommand({ platform }); + await doctorCommand(); break; } - case 'mcp': { - const agentInput = cli.input[1]; - const validAgents = getValidMCPAgents(); - if (agentInput && !isValidMCPAgent(agentInput)) { - console.error(`Unknown agent: ${agentInput}`); - console.error(`Supported agents: ${validAgents.join(', ')}`); + case 'mcp': + if (cli.input[1]) { + console.error('mcp command does not accept positional arguments.'); process.exit(1); } - const agent = agentInput as MCPTargetAgent | undefined; - await mcpCommand({ agent }); + await mcpCommand(); break; - } case 'skills': await skillsCommand(); diff --git a/src/commands/__tests__/mcp.test.ts b/src/commands/__tests__/mcp.test.ts index 1ba7768..14d65e5 100644 --- a/src/commands/__tests__/mcp.test.ts +++ b/src/commands/__tests__/mcp.test.ts @@ -11,29 +11,8 @@ describe('mcpCommand', () => { runCommandHandoffMock.mockResolvedValue(0); }); - test('hands off with mapped add-mcp agent when agent is provided', async () => { - await mcpCommand({ agent: 'codex' }, { runHandoff: runCommandHandoffMock }); - - expect(runCommandHandoffMock).toHaveBeenCalledTimes(1); - expect(runCommandHandoffMock).toHaveBeenCalledWith({ - command: 'npx', - args: [ - '-y', - 'add-mcp', - 'npx -y https://github.com/clix-so/clix-mcp-server', - '--name', - 'clix-mcp-server', - '--global', - '--agent', - 'codex', - ], - workingDirectory: process.cwd(), - displayName: 'add-mcp', - }); - }); - - test('hands off without --agent when agent is omitted', async () => { - await mcpCommand({}, { runHandoff: runCommandHandoffMock }); + test('hands off with fixed add-mcp arguments', async () => { + await mcpCommand({ runHandoff: runCommandHandoffMock }); expect(runCommandHandoffMock).toHaveBeenCalledTimes(1); const invocation = runCommandHandoffMock.mock.calls[0]?.[0]; @@ -46,12 +25,10 @@ describe('mcpCommand', () => { expect(invocation.command).toBe('npx'); expect(invocation.displayName).toBe('add-mcp'); expect(invocation.args).toEqual([ - '-y', 'add-mcp', - 'npx -y https://github.com/clix-so/clix-mcp-server', + '@clix-so/clix-mcp-server@latest', '--name', - 'clix-mcp-server', - '--global', + 'clix', ]); }); }); diff --git a/src/commands/doctor.tsx b/src/commands/doctor.tsx index 80c3852..1a1aaa4 100644 --- a/src/commands/doctor.tsx +++ b/src/commands/doctor.tsx @@ -1,12 +1,7 @@ import { skillCommand } from './skill/index'; -interface DoctorCommandOptions { - platform?: 'ios' | 'android' | 'react-native' | 'flutter'; -} - -export async function doctorCommand(options: DoctorCommandOptions = {}): Promise { +export async function doctorCommand(): Promise { await skillCommand({ action: 'doctor', - platform: options.platform, }); } diff --git a/src/commands/install.tsx b/src/commands/install.tsx index 13acb70..e042843 100644 --- a/src/commands/install.tsx +++ b/src/commands/install.tsx @@ -1,14 +1,12 @@ import { skillCommand } from './skill/index'; interface InstallCommandOptions { - platform?: 'ios' | 'android' | 'react-native' | 'flutter'; startTask?: string; } export async function installCommand(options: InstallCommandOptions = {}): Promise { await skillCommand({ action: 'install', - platform: options.platform, startTask: options.startTask, }); } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 293e15b..7a86d82 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,45 +1,18 @@ import { runCommandHandoff } from '../lib/services/agent-handoff'; -import { getMCPAgentConfigs, type MCPTargetAgent } from '../lib/services/mcp-install-service'; const ADD_MCP_PACKAGE = 'add-mcp'; -const CLIX_MCP_SERVER_REPO = 'https://github.com/clix-so/clix-mcp-server'; -const MCP_SERVER_NAME = 'clix-mcp-server'; - -interface MCPCommandOptions { - agent?: MCPTargetAgent; -} +const CLIX_MCP_SERVER_PACKAGE = '@clix-so/clix-mcp-server@latest'; +const MCP_SERVER_NAME = 'clix'; interface MCPCommandDependencies { runHandoff?: typeof runCommandHandoff; } -function buildMcpHandoffArgs(agent?: MCPTargetAgent): string[] { - const args = [ - '-y', - ADD_MCP_PACKAGE, - `npx -y ${CLIX_MCP_SERVER_REPO}`, - '--name', - MCP_SERVER_NAME, - '--global', - ]; - - if (!agent) { - return args; - } - - const targetAgentConfig = getMCPAgentConfigs().find((config) => config.name === agent); - if (!targetAgentConfig) { - throw new Error(`Unknown agent: ${agent}`); - } - - args.push('--agent', targetAgentConfig.addMcpAgentId); - return args; +function buildMcpHandoffArgs(): string[] { + return [ADD_MCP_PACKAGE, CLIX_MCP_SERVER_PACKAGE, '--name', MCP_SERVER_NAME]; } -export async function mcpCommand( - options: MCPCommandOptions, - dependencies: MCPCommandDependencies = {}, -): Promise { +export async function mcpCommand(dependencies: MCPCommandDependencies = {}): Promise { const runHandoff = dependencies.runHandoff ?? runCommandHandoff; console.log('Adding Clix MCP Server...'); @@ -48,7 +21,7 @@ export async function mcpCommand( try { exitCode = await runHandoff({ command: 'npx', - args: buildMcpHandoffArgs(options.agent), + args: buildMcpHandoffArgs(), workingDirectory: process.cwd(), displayName: 'add-mcp', }); diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 3781e01..4392e8d 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -13,7 +13,6 @@ import type { PreparationContext } from './preparation'; interface SkillCommandOptions { action?: string; - platform?: 'ios' | 'android' | 'react-native' | 'flutter'; startTask?: string; } @@ -32,12 +31,10 @@ Supported commands: doctor Check Clix SDK integration status Options: - --platform Target platform (ios, android, react-native, flutter) --start-task Development-only install task override Examples: $ clix install - $ clix install --platform ios $ clix doctor `; } @@ -86,7 +83,7 @@ async function loadCommandAgent(): Promise { } export async function skillCommand(options: SkillCommandOptions): Promise { - const { action, platform, startTask } = options; + const { action, startTask } = options; if (!action || !isCommandSkillType(action)) { console.log(generateHelpText()); @@ -137,7 +134,6 @@ export async function skillCommand(options: SkillCommandOptions): Promise const prompt = await getSkillPrompt(skillType, { projectPath, - platform, preparationContext, }); diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index 1756a8c..7825776 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -31,19 +31,20 @@ describe('getSkillPrompt', () => { expect(prompt).toContain('Project path: /tmp/project'); expect(prompt).toContain('analyzing a mobile project for Clix SDK'); + expect(prompt).toContain('Final Result: HEALTHY | ACTION_NEEDED | FAILED'); }); test('builds install prompt with integration goal', async () => { const prompt = await getSkillPrompt('install', { projectPath: '/tmp/project', - platform: 'ios', }); expect(prompt).toContain('Project path: /tmp/project'); - expect(prompt).toContain('Target platform: ios'); + expect(prompt).toContain('Target platform: auto-detect'); expect(prompt).toContain( 'Execution goal: Complete SDK integration workflow using the pre-configured setup context.', ); + expect(prompt).toContain('Final Result: SUCCESS | PARTIAL | FAILED'); expect(prompt).not.toContain('non-interactive one-shot execution'); expect(prompt).not.toContain('Install phase:'); expect(prompt).not.toContain('project-build'); diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index f0f2010..ac8496a 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -125,6 +125,14 @@ At completion, report: - commands executed - placeholders still requiring real values - hard blockers (if any) + +At the very end, always include a final status block in this exact shape: + +Final Result: SUCCESS | PARTIAL | FAILED +- Summary: one concise sentence +- Modified files: comma-separated list (or \`none\`) +- Verification: passed | failed | not run +- Remaining blockers: \`none\` or concrete blockers `, 'local-doctor': `# Clix SDK Doctor @@ -226,6 +234,14 @@ Analyze the project and output a diagnostic JSON report: Output the JSON diagnostic, then provide a brief summary with actionable recommendations. +At the very end, always include a final status block in this exact shape: + +Final Result: HEALTHY | ACTION_NEEDED | FAILED +- Summary: one concise sentence +- Critical issues: number +- Warnings: number +- Recommended next action: single highest-priority action + Use \`/install\` to run interactive setup for missing Firebase credentials. `, }; diff --git a/src/lib/skills.ts b/src/lib/skills.ts index f547354..e7fa5ae 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -9,7 +9,6 @@ export type SkillType = string; export interface SkillOptions { projectPath?: string; - platform?: 'ios' | 'android' | 'react-native' | 'flutter'; signal?: AbortSignal; oneShot?: boolean; preparationContext?: PreparationContext; @@ -250,7 +249,7 @@ function getInstallPrompt(options?: SkillOptions): string { ? 'android' : undefined : undefined; - const platform = options?.platform ?? inferredPlatform ?? 'auto-detect'; + const platform = inferredPlatform ?? 'auto-detect'; let prompt = `Project path: ${projectPath}\nTarget platform: ${platform}\n`; if (context) { diff --git a/src/lib/skills/doctor/SKILL.md b/src/lib/skills/doctor/SKILL.md index 58926d4..a5e0e9c 100644 --- a/src/lib/skills/doctor/SKILL.md +++ b/src/lib/skills/doctor/SKILL.md @@ -98,4 +98,12 @@ Analyze the project and output a diagnostic JSON report: Output the JSON diagnostic, then provide a brief summary with actionable recommendations. +At the very end, always include a final status block in this exact shape: + +Final Result: HEALTHY | ACTION_NEEDED | FAILED +- Summary: one concise sentence +- Critical issues: number +- Warnings: number +- Recommended next action: single highest-priority action + Use `/install` to run interactive setup for missing Firebase credentials. diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index 1a7de39..e222770 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -101,3 +101,11 @@ At completion, report: - commands executed - placeholders still requiring real values - hard blockers (if any) + +At the very end, always include a final status block in this exact shape: + +Final Result: SUCCESS | PARTIAL | FAILED +- Summary: one concise sentence +- Modified files: comma-separated list (or `none`) +- Verification: passed | failed | not run +- Remaining blockers: `none` or concrete blockers diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index ac38b56..2900080 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -45,8 +45,7 @@ describe('CLI E2E Tests', () => { expect(result.stdout).toContain('--help'); expect(result.stdout).toContain('--version'); - expect(result.stdout).toContain('--platform'); - expect(result.stdout).toContain('ios, android, react-native, flutter'); + expect(result.stdout).not.toContain('--platform'); }); test('should show examples', async () => { From a40fff48a99f2ae4b549d577bcfd5fee8dcb6766 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Sun, 22 Feb 2026 01:40:12 -0800 Subject: [PATCH 18/30] refactor(doctor): add pre-check verification and interactive status UI before agent handoff Doctor command now gathers PreparationContext (reusing install's gatherPreparationContext) to verify Firebase, APNS, iOS entitlements, and NSE status before agent handoff. Shows colored status checklist using shared StatusLine/getStatusRows components. Missing items block handoff with clix install guidance. Ready state prompts user to accept AI diagnosis via GenericSelector. SKILL.md updated to remove JSON output in favor of readable text format, with pre-verified context as ground truth. --- src/commands/skill/index.tsx | 123 ++++++++++++++++++- src/lib/__tests__/skills.test.ts | 77 ++++++++++++ src/lib/skills.ts | 49 +++++++- src/lib/skills/doctor/SKILL.md | 133 ++++++++------------- src/ui/components/InstallPreparationUI.tsx | 8 +- 5 files changed, 296 insertions(+), 94 deletions(-) diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 4392e8d..ffb9e29 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -1,15 +1,24 @@ +import { Box, Text } from 'ink'; +import type React from 'react'; +import { useCallback } from 'react'; import type { AgentInfo } from '../../lib/agents'; import { getConfigManager } from '../../lib/config/index'; import { buildAgentHandoffInvocation, runAgentHandoff } from '../../lib/services/agent-handoff'; import { AgentSelectionService } from '../../lib/services/agent-selection-service'; import { getSkillPrompt } from '../../lib/skills'; -import { InstallPreparationUI } from '../../ui/components/InstallPreparationUI'; +import { GenericSelector, type SelectorItem } from '../../ui/components/GenericSelector'; +import { + getStatusLayoutPolicy, + getStatusRows, + InstallPreparationUI, + StatusLine, +} from '../../ui/components/InstallPreparationUI'; import { INSTALL_TASK_LABELS, type InstallTaskId, } from '../../ui/components/install-preparation-tasks'; import { safeRender } from '../../ui/utils/safeRender'; -import type { PreparationContext } from './preparation'; +import { gatherPreparationContext, type PreparationContext } from './preparation'; interface SkillCommandOptions { action?: string; @@ -66,6 +75,105 @@ async function runInstallPreparation( }); } +interface DoctorActionItem extends SelectorItem { + action: 'diagnose' | 'exit'; +} + +function DoctorStatusDisplay({ + context, + onAccept, + onCancel, +}: { + context: PreparationContext; + onAccept?: () => void; + onCancel: () => void; +}): React.ReactElement { + const layoutPolicy = getStatusLayoutPolicy(); + const statusRows = getStatusRows(context, layoutPolicy, {}, false); + + const items: DoctorActionItem[] = [ + { id: 'diagnose', label: 'Run AI diagnosis', action: 'diagnose' }, + { id: 'exit', label: 'Exit', action: 'exit' }, + ]; + + const handleSelect = useCallback( + (item: DoctorActionItem) => { + if (item.action === 'diagnose') { + onAccept?.(); + } else { + onCancel(); + } + }, + [onAccept, onCancel], + ); + + return ( + + Clix SDK Doctor — Pre-check Status + + {statusRows.map((row) => ( + + ))} + + + {!context.ready && ( + <> + + Missing setup: + {context.missing.map((item) => ( + + {' '}• {item} + + ))} + + + Run "clix install" to complete the required setup before + running doctor. + + + )} + + {context.ready && onAccept && ( + <> + + Additional SDK integration issues can be diagnosed with an AI agent. + + + + )} + + ); +} + +async function runDoctorPrecheck(context: PreparationContext): Promise<'accepted' | 'cancelled'> { + return new Promise((resolve) => { + const { unmount } = safeRender( + { + unmount(); + resolve('accepted'); + } + : undefined + } + onCancel={() => { + unmount(); + resolve('cancelled'); + }} + />, + ); + + if (!context.ready) { + setTimeout(() => { + unmount(); + resolve('cancelled'); + }, 0); + } + }); +} + async function loadCommandAgent(): Promise { const configManager = getConfigManager(); const selectionService = new AgentSelectionService(configManager); @@ -123,6 +231,17 @@ export async function skillCommand(options: SkillCommandOptions): Promise return; } preparationContext = context; + } else if (skillType === 'doctor') { + const context = await gatherPreparationContext(projectPath); + if (!context) { + console.error('Project not linked. Run "clix login" first.'); + process.exit(1); + } + const result = await runDoctorPrecheck(context); + if (result === 'cancelled') { + process.exit(context.ready ? 0 : 1); + } + preparationContext = context; } const agent = await loadCommandAgent(); diff --git a/src/lib/__tests__/skills.test.ts b/src/lib/__tests__/skills.test.ts index 7825776..15f0521 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -110,6 +110,83 @@ describe('getSkillPrompt', () => { expect(prompt).toContain('Project Public API Key: pk_test_public_key_123'); }); + test('builds doctor prompt with preparation context', async () => { + const preparationContext: PreparationContext = { + projectPath: '/tmp/project', + config: { + version: 1, + member: { + id: 'member-1', + email: 'member@example.com', + name: 'Member', + }, + organization: { + id: 'org-1', + name: 'Org', + }, + project: { + id: 'project-1', + name: 'Project', + public_api_key: 'pk_test_doctor_key_456', + }, + linkedAt: '2026-01-01T00:00:00.000Z', + }, + projectType: { + framework: 'react-native', + target: 'ios-android', + }, + firebase: { + configured: true, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + senderConfigProjectMatched: true, + projectId: 'firebase-project', + needed: true, + }, + ios: { + needed: true, + bundleId: 'com.example.app', + teamId: 'TEAM123456', + appGroupId: 'group.clix.com.example.app', + entitlementsConfigured: true, + nseConfigured: true, + }, + apns: { + needed: true, + keyId: 'KEY1234567', + teamId: 'TEAM123456', + registeredWithFirebase: true, + }, + ready: true, + missing: [], + }; + + const prompt = await getSkillPrompt('doctor', { + projectPath: '/tmp/project', + preparationContext, + }); + + expect(prompt).toContain('Pre-verified Status'); + expect(prompt).toContain('Project: Project'); + expect(prompt).toContain('react-native'); + expect(prompt).toContain('firebase-project'); + expect(prompt).toContain('Project Public API Key: pk_test_doctor_key_456'); + expect(prompt).toContain('pre-verified by clix before agent handoff'); + expect(prompt).not.toContain('clix install preparation'); + expect(prompt).toContain('analyzing a mobile project for Clix SDK'); + expect(prompt).toContain('Final Result: HEALTHY | ACTION_NEEDED | FAILED'); + }); + + test('builds doctor prompt without context when not provided', async () => { + const prompt = await getSkillPrompt('doctor', { projectPath: '/tmp/project' }); + + expect(prompt).not.toContain('## Pre-configured Setup\n'); + expect(prompt).not.toContain('Detected project type:'); + expect(prompt).toContain('Project path: /tmp/project'); + expect(prompt).toContain('analyzing a mobile project for Clix SDK'); + }); + test('rejects non-local skill types', async () => { await expect(getSkillPrompt('integration')).rejects.toThrow('Unknown command type'); }); diff --git a/src/lib/skills.ts b/src/lib/skills.ts index e7fa5ae..681b4f6 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -134,7 +134,15 @@ function appendOptionalSection( lines.push(''); } -function buildPreparationSection(context: PreparationContext): string { +interface PreparationSectionOptions { + verificationTitle?: string; + footerLines?: string[]; +} + +function buildPreparationSection( + context: PreparationContext, + options?: PreparationSectionOptions, +): string { const lines: string[] = ['## Pre-configured Setup', '']; lines.push(`Project: ${context.config.project.name}`); lines.push(`Type: ${formatProjectType(context.projectType)}`); @@ -148,7 +156,7 @@ function buildPreparationSection(context: PreparationContext): string { !context.firebase.needed || ((!iosTarget || context.firebase.iosConfigured) && (!androidTarget || context.firebase.androidConfigured)); - appendOptionalSection(lines, 'Install Step Verification', [ + appendOptionalSection(lines, options?.verificationTitle ?? 'Setup Verification', [ 'Project linked: ✓ verified', `Firebase Configuration Files: ${formatSetupStepStatus( context.firebase.needed, @@ -226,8 +234,13 @@ function buildPreparationSection(context: PreparationContext): string { context.missing.length > 0, ); - lines.push('Treat the above as already executed/validated by clix install preparation.'); - lines.push('Use these pre-configured values when running build and troubleshooting failures.'); + const footer = options?.footerLines ?? [ + 'This context was pre-verified by clix before agent handoff.', + 'Use these values as ground truth for your analysis.', + ]; + for (const line of footer) { + lines.push(line); + } lines.push(''); return lines.join('\n'); @@ -258,7 +271,13 @@ function getInstallPrompt(options?: SkillOptions): string { prompt += '\n'; if (context) { - prompt += buildPreparationSection(context); + prompt += buildPreparationSection(context, { + verificationTitle: 'Install Step Verification', + footerLines: [ + 'Treat the above as already executed/validated by clix install preparation.', + 'Use these pre-configured values when running build and troubleshooting failures.', + ], + }); prompt += '\n'; } @@ -276,7 +295,25 @@ function getInstallPrompt(options?: SkillOptions): string { function getDoctorPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); - let prompt = `Project path: ${projectPath}\n\n`; + const context = options?.preparationContext; + + let prompt = `Project path: ${projectPath}\n`; + if (context) { + prompt += `Detected project type: ${formatProjectType(context.projectType)}\n`; + } + prompt += '\n'; + + if (context) { + prompt += buildPreparationSection(context, { + verificationTitle: 'Pre-verified Status', + footerLines: [ + 'This context was pre-verified by clix before agent handoff.', + 'Use these values as ground truth. Do not re-scan for information already provided above.', + 'Focus your analysis on SDK integration issues, version checks, and actionable recommendations.', + ], + }); + prompt += '\n'; + } if (options?.oneShot) { prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; diff --git a/src/lib/skills/doctor/SKILL.md b/src/lib/skills/doctor/SKILL.md index a5e0e9c..db7d143 100644 --- a/src/lib/skills/doctor/SKILL.md +++ b/src/lib/skills/doctor/SKILL.md @@ -2,101 +2,70 @@ You are analyzing a mobile project for Clix SDK integration status. -## Task - -Analyze the project and output a diagnostic JSON report: - -```json -{ - "platform": "ios" | "android" | "react-native" | "flutter" | "unknown", - "installationMethod": "spm-package-swift" | "spm-xcode" | "cocoapods" | "npm" | "gradle" | "pubspec" | "none", - "sdkInstalled": true | false, - "sdkVersion": "version string or null", - "pushConfigured": true | false, - "issues": [ - { - "type": "sdk" | "push" | "config" | "general", - "severity": "error" | "warning" | "info", - "file": "File name or path", - "description": "Problem explanation", - "recommendation": "Fix steps" - } - ], - "checklist": { - "sdkDependency": true | false, - "apiKeyConfigured": true | false, - "pushPermissions": true | false, - "entitlements": true | false, - "firebaseConfig": true | false, - "firebaseAndroid": true | false, - "firebaseIos": true | false, - "firebasePackageMatch": true | false, - "firebaseBundleMatch": true | false - }, - "nextSteps": ["Step 1", "Step 2"] -} -``` +## Pre-verified Context -## Analysis Checklist +A "Pre-verified Status" section is provided above. All setup prerequisites (Firebase, APNS, iOS entitlements, NSE) have been verified as complete before this handoff. Use it as ground truth — do NOT re-scan for these items. + +Focus your analysis exclusively on items NOT covered by pre-verification: +- SDK dependency presence, version, and update availability +- SDK initialization code in the app startup path (e.g., Clix.initialize() call) +- API key configuration in code (correct key, correct placement) +- Push notification permission request implementation +- Build configuration and dependency resolution issues +- Runtime integration quality (missing method calls, incorrect usage patterns) +- Firebase config cross-validation (package name / bundle ID match) -### Platform Detection -1. Check for package.json (React Native/Expo) -2. Check for pubspec.yaml (Flutter) -3. Check for *.xcodeproj or *.xcworkspace (iOS) -4. Check for build.gradle or AndroidManifest.xml (Android) +## Analysis Checklist ### SDK Installation Check - **iOS**: Check in order of priority: - 1. `Package.swift` with iOS platform target (contains `.iOS` or `platforms: [.iOS`) for package dependency containing `clix-ios-sdk` or `clix` → `installationMethod: "spm-package-swift"` - 2. `Podfile` for 'ClixSDK' or 'Clix' pod → `installationMethod: "cocoapods"` - 3. `*.xcodeproj/project.pbxproj` for `XCRemoteSwiftPackageReference` containing `clix` → `installationMethod: "spm-xcode"` -- Android: Check build.gradle for clix dependency → `installationMethod: "gradle"` -- React Native: Check package.json for '@clix-so/react-native-sdk' → `installationMethod: "npm"` -- Flutter: Check pubspec.yaml for 'clix_flutter_sdk' → `installationMethod: "pubspec"` + 1. `Package.swift` with iOS platform target (contains `.iOS` or `platforms: [.iOS`) for package dependency containing `clix-ios-sdk` or `clix` — SPM (Package.swift) + 2. `Podfile` for 'ClixSDK' or 'Clix' pod — CocoaPods + 3. `*.xcodeproj/project.pbxproj` for `XCRemoteSwiftPackageReference` containing `clix` — SPM (Xcode) +- Android: Check build.gradle for clix dependency +- React Native: Check package.json for '@clix-so/react-native-sdk' +- Flutter: Check pubspec.yaml for 'clix_flutter_sdk' ### Push Configuration Check -- iOS: Check entitlements for 'aps-environment' -- Android: Check AndroidManifest.xml for FCM service -- Check for google-services.json (Android) or GoogleService-Info.plist (iOS) - -### Firebase Configuration Check (Detailed) - -**Android (google-services.json):** -- Check file presence in expected locations: - - Standard Android: `app/google-services.json` - - React Native/Flutter: `android/app/google-services.json` -- Validate JSON structure against Firebase schema -- Verify `project_info.project_id` exists -- Verify `client[].client_info.android_client_info.package_name` matches AndroidManifest.xml -- Report if file found in wrong location (e.g., project root) - -**iOS (GoogleService-Info.plist):** -- Check file presence in expected locations: - - React Native: `ios/GoogleService-Info.plist` - - Flutter: `ios/Runner/GoogleService-Info.plist` - - Native iOS: `/GoogleService-Info.plist` -- Validate plist structure (API_KEY, GCM_SENDER_ID, GOOGLE_APP_ID, PROJECT_ID, BUNDLE_ID) -- Verify BUNDLE_ID matches Xcode project bundle identifier -- Report if file found in wrong location - -**Cross-Platform Validation:** -- For React Native/Flutter projects, verify both Android and iOS configs exist -- Verify PROJECT_ID matches between platforms +- iOS: Check code for push notification permission request (UNUserNotificationCenter requestAuthorization) +- Android: Check AndroidManifest.xml for FCM service declaration + +### Firebase Cross-validation +Firebase config file presence is already pre-verified. Only perform these cross-validations: +- **Android**: Verify `client[].client_info.android_client_info.package_name` in google-services.json matches AndroidManifest.xml package +- **iOS**: Verify `BUNDLE_ID` in GoogleService-Info.plist matches Xcode project bundle identifier +- **Cross-platform**: For React Native/Flutter projects, verify PROJECT_ID matches between Android and iOS configs ### Common Issues to Detect - Missing SDK dependency -- Missing or invalid API key -- Missing push notification permissions -- Missing capabilities/entitlements +- Missing or invalid API key in code +- Missing push notification permission request in code - Outdated SDK version -- Incomplete Firebase/APNs setup -- Firebase config file missing -- Firebase config file in wrong location -- Firebase config file invalid (malformed JSON/plist) - Firebase package name / bundle ID mismatch - Firebase project ID mismatch between platforms +- Missing SDK initialization call +- Incorrect SDK initialization order or placement + +## Output Format + +Output your analysis as readable text with these sections: + +### SDK Status +- SDK installed: yes/no +- Installation method: (method name) +- SDK version: (version or "not detected") +- API key configured: yes/no + +### Issues Found +List each issue with severity: +- [ERROR] Description — file path — recommended fix +- [WARNING] Description — file path — recommended fix +- [INFO] Description + +If no issues found, state "No issues detected." -Output the JSON diagnostic, then provide a brief summary with actionable recommendations. +### Recommended Actions +List action items in priority order. If everything is healthy, state "No actions needed." At the very end, always include a final status block in this exact shape: @@ -106,4 +75,4 @@ Final Result: HEALTHY | ACTION_NEEDED | FAILED - Warnings: number - Recommended next action: single highest-priority action -Use `/install` to run interactive setup for missing Firebase credentials. +Use 'clix install' to set up SDK integration if not yet installed. diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index 5c7aa04..28d13fa 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -196,9 +196,9 @@ interface ActionItem extends SelectorItem { action: 'continue' | 'cancel'; } -type StatusLineState = 'pending' | 'checking' | 'ok' | 'missing' | 'skipped'; +export type StatusLineState = 'pending' | 'checking' | 'ok' | 'missing' | 'skipped'; -interface StatusRow { +export interface StatusRow { label: string; status: StatusLineState; detail?: string; @@ -403,7 +403,7 @@ function getTaskDetail( } } -function getStatusRows( +export function getStatusRows( context: PreparationContext, layoutPolicy: StatusLayoutPolicy, runtimeTaskState: RuntimeTaskStateMap, @@ -445,7 +445,7 @@ function getStatusRows( return [...rows, ...taskRows]; } -function StatusLine({ +export function StatusLine({ label, status, detail, From d9d63c8dafd98cf749bd4e89455afdddb9b8a7f0 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Sun, 22 Feb 2026 21:24:55 -0800 Subject: [PATCH 19/30] fix(install): harden firebase and iOS setup validation flows Improve install preparation behavior for Firebase and iOS setup tasks. - add Android/iOS identifier mismatch handling and app creation paths - detect Android package matches across all google-services clients - show Apple Team bundle IDs when bundle identifier is unavailable - tighten iOS entitlements detection and Apple auth UX handling - remove generated config header comments and align setup status checks --- .../skill/__tests__/preparation.test.ts | 28 ++ src/commands/skill/preparation.ts | 177 ++++++++-- src/lib/config/project-config-manager.ts | 4 +- src/lib/embedded-skills.ts | 133 +++----- src/lib/ios/apple-auth.ts | 27 +- src/lib/ios/apple-portal.ts | 100 +++++- src/lib/services/project-detector.ts | 42 +++ src/ui/IosSetupUI.tsx | 205 +++++++---- .../components/FirebaseConfigFilesSetup.tsx | 112 ++++++- src/ui/components/InstallPreparationUI.tsx | 317 +++++++++++++++--- .../ios-setup/IosEntitlementsTask.tsx | 2 +- 11 files changed, 908 insertions(+), 239 deletions(-) diff --git a/src/commands/skill/__tests__/preparation.test.ts b/src/commands/skill/__tests__/preparation.test.ts index 3aab6f2..3d99f15 100644 --- a/src/commands/skill/__tests__/preparation.test.ts +++ b/src/commands/skill/__tests__/preparation.test.ts @@ -422,6 +422,34 @@ describe('preparation', () => { expect(status.entitlementsConfigured).toBe(true); expect(status.nseConfigured).toBe(true); }); + + test('should not treat extension entitlements as main iOS entitlements', async () => { + const projectType: ProjectType = { framework: 'native', target: 'ios' }; + + mockIos.analyzeIosProject.mockResolvedValueOnce({ + success: true, + project: { + projectPath: '/test/ios/MyApp.xcodeproj', + workspacePath: '/test/ios/MyApp.xcworkspace', + bundleId: 'com.test.app', + appName: 'MyApp', + targets: ['MyApp', 'MyAppNotificationServiceExtension'], + entitlementsFiles: [ + '/test/ios/MyAppNotificationServiceExtension/MyAppNotificationServiceExtension.entitlements', + ], + teamId: 'TEAM123456', + }, + }); + mockIos.readEntitlements.mockResolvedValueOnce({ + 'aps-environment': 'development', + 'com.apple.security.application-groups': ['group.clix.com.test.app'], + }); + mockIos.hasClixConfiguration.mockReturnValueOnce({ hasPush: true, hasAppGroup: true }); + + const status = await checkIosStatus('/test', projectType); + + expect(status.entitlementsConfigured).toBe(false); + }); }); describe('checkApnsStatus', () => { diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts index 6a9d454..5fb2d27 100644 --- a/src/commands/skill/preparation.ts +++ b/src/commands/skill/preparation.ts @@ -7,6 +7,7 @@ * @module commands/skill/preparation */ +import * as path from 'node:path'; import type { SenderConfig } from '@/lib/api'; import { getInternalApiClient } from '@/lib/api'; import { @@ -33,8 +34,8 @@ import { } from '@/lib/ios'; import { FirebaseService } from '@/lib/services/firebase/firebase-service'; import { parseBase64ServiceAccountJson } from '@/lib/services/firebase/service-account-validator'; -import type { FirebaseDetectionResult } from '@/lib/services/firebase/types'; -import { detectProjectType } from '@/lib/services/project-detector'; +import type { FirebaseDetectionResult, GoogleServicesJson } from '@/lib/services/firebase/types'; +import { detectAndroidPackageName, detectProjectType } from '@/lib/services/project-detector'; /** * Status of Firebase configuration. @@ -54,6 +55,18 @@ export interface FirebaseStatus { projectId?: string; /** Whether Firebase setup is needed based on project type */ needed: boolean; + /** BUNDLE_ID detected from GoogleService-Info.plist */ + detectedIosBundleId?: string; + /** Whether plist BUNDLE_ID matches the project's actual bundle ID */ + iosBundleIdMatched?: boolean; + /** package_name detected from google-services.json */ + detectedAndroidPackageName?: string; + /** All package_name values detected from google-services.json clients */ + detectedAndroidPackageNames?: string[]; + /** Whether google-services.json package_name matches the project's actual package name */ + androidPackageNameMatched?: boolean; + /** Actual Android package name detected from project files */ + actualAndroidPackageName?: string; } interface SenderConfigStatus { @@ -61,6 +74,16 @@ interface SenderConfigStatus { projectMatched: boolean; } +function extractAndroidPackageNamesFromDetection(detection: FirebaseDetectionResult): string[] { + if (!detection.android?.content || !('client' in detection.android.content)) { + return []; + } + + return (detection.android.content as GoogleServicesJson).client + .map((client) => client.client_info?.android_client_info?.package_name) + .filter((value): value is string => typeof value === 'string' && value.length > 0); +} + /** * Status of iOS configuration. */ @@ -392,6 +415,22 @@ export async function checkFirebaseStatus( projectId = setup.firebase.projectId; } + // Extract BUNDLE_ID from detected iOS plist for cross-validation + let detectedIosBundleId: string | undefined; + if (detection.ios?.content && 'BUNDLE_ID' in detection.ios.content) { + const bundleId = (detection.ios.content as { BUNDLE_ID?: string }).BUNDLE_ID; + if (typeof bundleId === 'string' && bundleId.length > 0) { + detectedIosBundleId = bundleId; + } + } + + // Extract package_name from detected Android google-services.json for cross-validation + const detectedAndroidPackageNames = extractAndroidPackageNamesFromDetection(detection); + let detectedAndroidPackageName: string | undefined; + if (detectedAndroidPackageNames.length > 0) { + detectedAndroidPackageName = detectedAndroidPackageNames[0]; + } + return { configured: credentialFilesConfigured && senderConfigConfigured && senderConfigProjectMatched, androidConfigured, @@ -399,6 +438,9 @@ export async function checkFirebaseStatus( senderConfigConfigured, senderConfigProjectMatched, projectId, + detectedIosBundleId, + detectedAndroidPackageName, + detectedAndroidPackageNames, needed: true, }; } @@ -435,10 +477,27 @@ async function detectIosStatusFromFiles( const project = analysis.project; const expectedAppGroupId = generateAppGroupId(project.bundleId); + const extensionName = getExtensionName(project.appName); let hasEntitlements = false; let detectedAppGroupId: string | undefined; - for (const entitlementsPath of project.entitlementsFiles) { + const mainTargetEntitlementsFiles = project.entitlementsFiles.filter((entitlementsPath) => { + const normalizedPath = entitlementsPath.toLowerCase(); + const extensionMarker = extensionName.toLowerCase(); + if (normalizedPath.includes(extensionMarker)) { + return false; + } + + const fileName = path.basename(entitlementsPath).toLowerCase(); + if (fileName === `${project.appName}.entitlements`.toLowerCase()) { + return true; + } + + const appDirMarker = `${path.sep}${project.appName.toLowerCase()}${path.sep}`; + return normalizedPath.includes(appDirMarker); + }); + + for (const entitlementsPath of mainTargetEntitlementsFiles) { try { const entitlements = await readEntitlements(entitlementsPath); const clixConfig = hasClixConfiguration(entitlements, project.bundleId); @@ -461,7 +520,6 @@ async function detectIosStatusFromFiles( } const iosDir = getIosProjectDir(projectPath); - const extensionName = getExtensionName(project.appName); const extensionBundleId = getExtensionBundleId(project.bundleId, project.appName); const filesComplete = iosDir ? verifyExtensionFiles(iosDir, project.appName).complete : false; @@ -581,6 +639,56 @@ export async function checkApnsStatus( }; } +function collectMissingItems(firebase: FirebaseStatus, ios: IosStatus, apns: ApnsStatus): string[] { + const missing: string[] = []; + + if (apns.needed && firebase.iosConfigured && !apns.registeredWithFirebase) { + missing.push('APNS Key for Firebase'); + } + + if (firebase.needed && !firebase.configured) { + if (!firebase.androidConfigured) { + if (firebase.androidPackageNameMatched === false) { + missing.push( + `Firebase Android config package name mismatch: expected "${firebase.actualAndroidPackageName}", got "${firebase.detectedAndroidPackageName}"`, + ); + } else { + missing.push('Firebase Android config (google-services.json)'); + } + } + if (!firebase.iosConfigured) { + if (firebase.iosBundleIdMatched === false) { + missing.push( + `Firebase iOS config bundle ID mismatch: expected "${ios.bundleId}", got "${firebase.detectedIosBundleId}"`, + ); + } else { + missing.push('Firebase iOS config (GoogleService-Info.plist)'); + } + } + const senderConfigPrerequisitesMet = + firebase.androidConfigured && + firebase.iosConfigured && + (!apns.needed || apns.registeredWithFirebase); + if ( + senderConfigPrerequisitesMet && + (!firebase.senderConfigConfigured || firebase.senderConfigProjectMatched === false) + ) { + missing.push('Firebase Service Account'); + } + } + + if (ios.needed) { + if (!ios.entitlementsConfigured) { + missing.push('iOS entitlements'); + } + if (!ios.nseConfigured) { + missing.push('Notification Service Extension'); + } + } + + return missing; +} + /** * Gather preparation context without running interactive setup. * This checks what's configured and what's missing. @@ -620,41 +728,42 @@ export async function gatherPreparationContext( // Step 5: Check APNS status const apns = await checkApnsStatus(projectPath, projectType, updatedConfig.setup); - // Step 6: Determine what's missing - const missing: string[] = []; - - if (apns.needed && firebase.iosConfigured && !apns.registeredWithFirebase) { - missing.push('APNS Key for Firebase'); + // Step 5.5: Cross-validate Firebase config identifiers with project identifiers + + // iOS: plist BUNDLE_ID vs project bundle ID + if ( + firebase.needed && + firebase.iosConfigured && + firebase.detectedIosBundleId && + ios.bundleId && + firebase.detectedIosBundleId !== ios.bundleId + ) { + firebase.iosConfigured = false; + firebase.iosBundleIdMatched = false; + firebase.configured = false; } - if (firebase.needed && !firebase.configured) { - if (!firebase.androidConfigured) { - missing.push('Firebase Android config (google-services.json)'); - } - if (!firebase.iosConfigured) { - missing.push('Firebase iOS config (GoogleService-Info.plist)'); - } - const senderConfigPrerequisitesMet = - firebase.androidConfigured && - firebase.iosConfigured && - (!apns.needed || apns.registeredWithFirebase); - if ( - senderConfigPrerequisitesMet && - (!firebase.senderConfigConfigured || firebase.senderConfigProjectMatched === false) - ) { - missing.push('Firebase Service Account'); - } - } - - if (ios.needed) { - if (!ios.entitlementsConfigured) { - missing.push('iOS entitlements'); - } - if (!ios.nseConfigured) { - missing.push('Notification Service Extension'); + // Android: detect actual package name from project files + if (firebase.needed) { + const actualAndroidPackage = await detectAndroidPackageName(projectPath); + if (actualAndroidPackage) { + firebase.actualAndroidPackageName = actualAndroidPackage; + // Cross-validate with google-services.json package_name + if ( + firebase.androidConfigured && + firebase.detectedAndroidPackageName && + firebase.detectedAndroidPackageNames && + !firebase.detectedAndroidPackageNames.includes(actualAndroidPackage) + ) { + firebase.androidConfigured = false; + firebase.androidPackageNameMatched = false; + firebase.configured = false; + } } } + // Step 6: Determine what's missing + const missing = collectMissingItems(firebase, ios, apns); const ready = missing.length === 0; return { diff --git a/src/lib/config/project-config-manager.ts b/src/lib/config/project-config-manager.ts index b1df69f..4341aef 100644 --- a/src/lib/config/project-config-manager.ts +++ b/src/lib/config/project-config-manager.ts @@ -14,9 +14,7 @@ import { /** * Comment header for the project config file. */ -const CONFIG_HEADER = `// Clix CLI 프로젝트 설정 -// 자동 생성됨 - 수동 수정 시 덮어쓰기될 수 있음 -`; +const CONFIG_HEADER = ''; /** * Gitignore patterns to check for .clix directory. diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index ac8496a..0abe9af 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -138,101 +138,70 @@ Final Result: SUCCESS | PARTIAL | FAILED You are analyzing a mobile project for Clix SDK integration status. -## Task - -Analyze the project and output a diagnostic JSON report: - -\`\`\`json -{ - "platform": "ios" | "android" | "react-native" | "flutter" | "unknown", - "installationMethod": "spm-package-swift" | "spm-xcode" | "cocoapods" | "npm" | "gradle" | "pubspec" | "none", - "sdkInstalled": true | false, - "sdkVersion": "version string or null", - "pushConfigured": true | false, - "issues": [ - { - "type": "sdk" | "push" | "config" | "general", - "severity": "error" | "warning" | "info", - "file": "File name or path", - "description": "Problem explanation", - "recommendation": "Fix steps" - } - ], - "checklist": { - "sdkDependency": true | false, - "apiKeyConfigured": true | false, - "pushPermissions": true | false, - "entitlements": true | false, - "firebaseConfig": true | false, - "firebaseAndroid": true | false, - "firebaseIos": true | false, - "firebasePackageMatch": true | false, - "firebaseBundleMatch": true | false - }, - "nextSteps": ["Step 1", "Step 2"] -} -\`\`\` +## Pre-verified Context -## Analysis Checklist +A "Pre-verified Status" section is provided above. All setup prerequisites (Firebase, APNS, iOS entitlements, NSE) have been verified as complete before this handoff. Use it as ground truth — do NOT re-scan for these items. + +Focus your analysis exclusively on items NOT covered by pre-verification: +- SDK dependency presence, version, and update availability +- SDK initialization code in the app startup path (e.g., Clix.initialize() call) +- API key configuration in code (correct key, correct placement) +- Push notification permission request implementation +- Build configuration and dependency resolution issues +- Runtime integration quality (missing method calls, incorrect usage patterns) +- Firebase config cross-validation (package name / bundle ID match) -### Platform Detection -1. Check for package.json (React Native/Expo) -2. Check for pubspec.yaml (Flutter) -3. Check for *.xcodeproj or *.xcworkspace (iOS) -4. Check for build.gradle or AndroidManifest.xml (Android) +## Analysis Checklist ### SDK Installation Check - **iOS**: Check in order of priority: - 1. \`Package.swift\` with iOS platform target (contains \`.iOS\` or \`platforms: [.iOS\`) for package dependency containing \`clix-ios-sdk\` or \`clix\` → \`installationMethod: "spm-package-swift"\` - 2. \`Podfile\` for 'ClixSDK' or 'Clix' pod → \`installationMethod: "cocoapods"\` - 3. \`*.xcodeproj/project.pbxproj\` for \`XCRemoteSwiftPackageReference\` containing \`clix\` → \`installationMethod: "spm-xcode"\` -- Android: Check build.gradle for clix dependency → \`installationMethod: "gradle"\` -- React Native: Check package.json for '@clix-so/react-native-sdk' → \`installationMethod: "npm"\` -- Flutter: Check pubspec.yaml for 'clix_flutter_sdk' → \`installationMethod: "pubspec"\` + 1. \`Package.swift\` with iOS platform target (contains \`.iOS\` or \`platforms: [.iOS\`) for package dependency containing \`clix-ios-sdk\` or \`clix\` — SPM (Package.swift) + 2. \`Podfile\` for 'ClixSDK' or 'Clix' pod — CocoaPods + 3. \`*.xcodeproj/project.pbxproj\` for \`XCRemoteSwiftPackageReference\` containing \`clix\` — SPM (Xcode) +- Android: Check build.gradle for clix dependency +- React Native: Check package.json for '@clix-so/react-native-sdk' +- Flutter: Check pubspec.yaml for 'clix_flutter_sdk' ### Push Configuration Check -- iOS: Check entitlements for 'aps-environment' -- Android: Check AndroidManifest.xml for FCM service -- Check for google-services.json (Android) or GoogleService-Info.plist (iOS) - -### Firebase Configuration Check (Detailed) - -**Android (google-services.json):** -- Check file presence in expected locations: - - Standard Android: \`app/google-services.json\` - - React Native/Flutter: \`android/app/google-services.json\` -- Validate JSON structure against Firebase schema -- Verify \`project_info.project_id\` exists -- Verify \`client[].client_info.android_client_info.package_name\` matches AndroidManifest.xml -- Report if file found in wrong location (e.g., project root) - -**iOS (GoogleService-Info.plist):** -- Check file presence in expected locations: - - React Native: \`ios/GoogleService-Info.plist\` - - Flutter: \`ios/Runner/GoogleService-Info.plist\` - - Native iOS: \`/GoogleService-Info.plist\` -- Validate plist structure (API_KEY, GCM_SENDER_ID, GOOGLE_APP_ID, PROJECT_ID, BUNDLE_ID) -- Verify BUNDLE_ID matches Xcode project bundle identifier -- Report if file found in wrong location - -**Cross-Platform Validation:** -- For React Native/Flutter projects, verify both Android and iOS configs exist -- Verify PROJECT_ID matches between platforms +- iOS: Check code for push notification permission request (UNUserNotificationCenter requestAuthorization) +- Android: Check AndroidManifest.xml for FCM service declaration + +### Firebase Cross-validation +Firebase config file presence is already pre-verified. Only perform these cross-validations: +- **Android**: Verify \`client[].client_info.android_client_info.package_name\` in google-services.json matches AndroidManifest.xml package +- **iOS**: Verify \`BUNDLE_ID\` in GoogleService-Info.plist matches Xcode project bundle identifier +- **Cross-platform**: For React Native/Flutter projects, verify PROJECT_ID matches between Android and iOS configs ### Common Issues to Detect - Missing SDK dependency -- Missing or invalid API key -- Missing push notification permissions -- Missing capabilities/entitlements +- Missing or invalid API key in code +- Missing push notification permission request in code - Outdated SDK version -- Incomplete Firebase/APNs setup -- Firebase config file missing -- Firebase config file in wrong location -- Firebase config file invalid (malformed JSON/plist) - Firebase package name / bundle ID mismatch - Firebase project ID mismatch between platforms +- Missing SDK initialization call +- Incorrect SDK initialization order or placement + +## Output Format + +Output your analysis as readable text with these sections: + +### SDK Status +- SDK installed: yes/no +- Installation method: (method name) +- SDK version: (version or "not detected") +- API key configured: yes/no + +### Issues Found +List each issue with severity: +- [ERROR] Description — file path — recommended fix +- [WARNING] Description — file path — recommended fix +- [INFO] Description + +If no issues found, state "No issues detected." -Output the JSON diagnostic, then provide a brief summary with actionable recommendations. +### Recommended Actions +List action items in priority order. If everything is healthy, state "No actions needed." At the very end, always include a final status block in this exact shape: @@ -242,7 +211,7 @@ Final Result: HEALTHY | ACTION_NEEDED | FAILED - Warnings: number - Recommended next action: single highest-priority action -Use \`/install\` to run interactive setup for missing Firebase credentials. +Use 'clix install' to set up SDK integration if not yet installed. `, }; diff --git a/src/lib/ios/apple-auth.ts b/src/lib/ios/apple-auth.ts index cfe3e52..d1ec16e 100644 --- a/src/lib/ios/apple-auth.ts +++ b/src/lib/ios/apple-auth.ts @@ -115,12 +115,23 @@ export async function promptAppleIdAsync( ): Promise { const lastAppleId = await getCachedUsernameAsync(); - console.log('› Log in to your Apple Developer account to continue'); + if (lastAppleId) { + console.log(`› Using cached Apple ID: ${lastAppleId}`); + return lastAppleId; + } - let username = await promptFn('Apple ID:', lastAppleId ?? undefined); + console.log('› Log in to your Apple Developer account to continue'); - // Remove any unprintable control characters (ASCII 0-31) - username = removeControlCharacters(username); + let username = ''; + while (!username.trim()) { + username = await promptFn('Apple ID:', lastAppleId ?? undefined); + username = removeControlCharacters(username); + if (!username.trim()) { + console.log( + '› Apple ID cannot be empty. Enter your Apple ID email or press Ctrl+C to cancel.', + ); + } + } if (username && username !== lastAppleId) { await cacheUsernameAsync(username); @@ -145,7 +156,13 @@ export async function promptPasswordAsync( console.log('› The password is only used to authenticate with Apple and never stored on servers'); - const password = await promptFn(`Password (for ${username}):`); + let password = ''; + while (!password) { + password = await promptFn(`Password (for ${username}):`); + if (!password) { + console.log('› Password cannot be empty. Enter your password or press Ctrl+C to cancel.'); + } + } await cachePasswordAsync(username, password); return password; diff --git a/src/lib/ios/apple-portal.ts b/src/lib/ios/apple-portal.ts index 5ab3365..276c08a 100644 --- a/src/lib/ios/apple-portal.ts +++ b/src/lib/ios/apple-portal.ts @@ -34,6 +34,66 @@ export interface CapabilitySyncResult { appGroupCreated: boolean; } +function isBundleIdentifierUnavailableError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.message.toLowerCase(); + return ( + message.includes('identifier') && + (message.includes('not available') || message.includes('invalid value')) + ); +} + +function extractBundleIdentifier(bundle: unknown): string | null { + if (typeof bundle !== 'object' || bundle === null) { + return null; + } + const record = bundle as Record; + + const directIdentifier = record.identifier; + if (typeof directIdentifier === 'string' && directIdentifier.length > 0) { + return directIdentifier; + } + + const attributes = record.attributes; + if (typeof attributes !== 'object' || attributes === null) { + return null; + } + + const identifier = (attributes as Record).identifier; + if (typeof identifier === 'string' && identifier.length > 0) { + return identifier; + } + + return null; +} + +async function getTeamBundleIdentifiers( + context: RequestContext, + maxItems = 10, +): Promise<{ identifiers: string[]; remainingCount: number }> { + try { + const bundles = await BundleId.getAsync(context, { query: { limit: 200 } }); + const identifiers = (bundles ?? []) + .map((bundle) => extractBundleIdentifier(bundle)) + .filter((identifier): identifier is string => Boolean(identifier)) + .sort((a, b) => a.localeCompare(b)); + + if (identifiers.length <= maxItems) { + return { identifiers, remainingCount: 0 }; + } + + return { + identifiers: identifiers.slice(0, maxItems), + remainingCount: identifiers.length - maxItems, + }; + } catch { + return { identifiers: [], remainingCount: 0 }; + } +} + /** * Create authentication context using API Key */ @@ -102,13 +162,41 @@ export async function findOrCreateBundleId( // Create new Bundle ID const displayName = name || bundleIdentifier.split('.').pop() || bundleIdentifier; - const newBundleId = await BundleId.createAsync(context, { - name: displayName, - identifier: bundleIdentifier, - platform: BundleIdPlatform.IOS, - }); + try { + const newBundleId = await BundleId.createAsync(context, { + name: displayName, + identifier: bundleIdentifier, + platform: BundleIdPlatform.IOS, + }); - return newBundleId; + return newBundleId; + } catch (error) { + if (!isBundleIdentifierUnavailableError(error)) { + throw error; + } + + // Fallback: in some cases create can fail even when the Bundle ID already exists in team. + const recheckedBundleId = await BundleId.findAsync(context, { + identifier: bundleIdentifier, + }); + if (recheckedBundleId) { + return recheckedBundleId; + } + + const { identifiers, remainingCount } = await getTeamBundleIdentifiers(context); + const bundleListMessage = + identifiers.length > 0 + ? `\nCurrent team Bundle IDs:\n- ${identifiers.join('\n- ')}${ + remainingCount > 0 ? `\n- ... and ${remainingCount} more` : '' + }` + : ''; + + throw new Error( + `Bundle ID "${bundleIdentifier}" is not available for the current Apple Team. ` + + 'Use a different bundle ID or switch to the Apple Team that owns this App ID.' + + bundleListMessage, + ); + } } /** diff --git a/src/lib/services/project-detector.ts b/src/lib/services/project-detector.ts index 4a621c4..e70e1a3 100644 --- a/src/lib/services/project-detector.ts +++ b/src/lib/services/project-detector.ts @@ -223,3 +223,45 @@ export function formatProjectType(type: ProjectType): string { return `${type.framework} (${targetLabel})`; } + +/** + * Detect Android package name from project files. + * + * Checks build.gradle namespace (AGP 7+) first, then AndroidManifest.xml package attribute. + * Supports both cross-platform (android/app/) and native Android (app/) layouts. + */ +export async function detectAndroidPackageName(projectPath: string): Promise { + const gradlePaths = [ + path.join(projectPath, 'android/app/build.gradle'), + path.join(projectPath, 'android/app/build.gradle.kts'), + path.join(projectPath, 'app/build.gradle'), + path.join(projectPath, 'app/build.gradle.kts'), + ]; + + for (const gradlePath of gradlePaths) { + try { + const content = await fs.readFile(gradlePath, 'utf-8'); + const match = content.match(/namespace\s*[=]?\s*["']([^"']+)["']/); + if (match?.[1]) return match[1]; + } catch { + // file not found + } + } + + const manifestPaths = [ + path.join(projectPath, 'android/app/src/main/AndroidManifest.xml'), + path.join(projectPath, 'app/src/main/AndroidManifest.xml'), + ]; + + for (const manifestPath of manifestPaths) { + try { + const content = await fs.readFile(manifestPath, 'utf-8'); + const match = content.match(/package\s*=\s*["']([^"']+)["']/); + if (match?.[1]) return match[1]; + } catch { + // file not found + } + } + + return undefined; +} diff --git a/src/ui/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx index f2eafdb..863044e 100644 --- a/src/ui/IosSetupUI.tsx +++ b/src/ui/IosSetupUI.tsx @@ -1,3 +1,4 @@ +import { createInterface } from 'node:readline'; import { Box, Text, useApp, useInput } from 'ink'; import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; @@ -6,19 +7,18 @@ import { analyzeIosProject, buildAgentContext, type CapabilitySyncResult, - createAuthContext, ensureMainTargetEntitlementsLink, generateAppGroupId, getAppleApiErrorMessage, getEntitlementsPath, getIosProjectDir, type IosProjectInfo, - loadApiKeyFromFile, type MainTargetEntitlementsLinkResult, readEntitlements, syncCapabilities, updateEntitlementsForClix, } from '@/lib/ios'; +import { getRequestContext, loginWithUserCredentialsAsync } from '@/lib/ios/apple-auth'; import { FirebaseService } from '@/lib/services/firebase'; import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; import { detectProjectType } from '@/lib/services/project-detector'; @@ -34,16 +34,8 @@ type SetupPhase = | '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'; } @@ -81,51 +73,93 @@ interface SetupState { phase: SetupPhase; projectInfo: IosProjectInfo | null; portalResult: CapabilitySyncResult | null; + portalSkipped: boolean; + portalSkipReason: string | null; + authStatusMessage: string; mainTargetEntitlementsLink: MainTargetEntitlementsLinkResult | null; updatedFiles: string[]; errorMessage: string; } +function promptTerminalInput(message: string, defaultValue?: string): Promise { + const stdin = process.stdin; + const canToggleRawMode = typeof stdin.isTTY === 'boolean' && stdin.isTTY && 'setRawMode' in stdin; + const wasRawModeEnabled = + canToggleRawMode && typeof stdin.isRaw === 'boolean' ? stdin.isRaw : false; + + if (canToggleRawMode && wasRawModeEnabled) { + (stdin as NodeJS.ReadStream).setRawMode(false); + } + + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + const prompt = defaultValue ? `${message} [${defaultValue}] ` : `${message} `; + rl.question(prompt, (answer) => { + rl.close(); + if (canToggleRawMode && wasRawModeEnabled) { + (stdin as NodeJS.ReadStream).setRawMode(true); + } + resolve(answer || defaultValue || ''); + }); + }); +} + +function promptTerminalPassword(message: string): Promise { + return promptTerminalInput(message); +} + +function promptTerminalConfirm(message: string): Promise { + return promptTerminalInput(`${message} (y/N)`).then( + (answer) => answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes', + ); +} + /** - * Sync capabilities with Apple Developer Portal + * Sync capabilities with Apple Developer Portal via Apple ID login. + * If the user cancels login, returns null and portal sync is skipped. */ async function syncWithPortal( - options: IosSetupOptions, bundleId: string, appGroupId: string, setPhase: (phase: SetupPhase) => void, + setAuthStatusMessage: (message: string) => void, + setPortalSkipReason: (message: string | null) => 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.', + const isBundleIdMismatchReason = (message: string): boolean => { + const normalized = message.toLowerCase(); + return ( + normalized.includes('not available') && + (normalized.includes('identifier') || normalized.includes('bundle id')) && + normalized.includes('apple team') ); - } + }; - if (options.skipPortal || !hasAllCreds) { + setPhase('authenticating'); + setPortalSkipReason(null); + setAuthStatusMessage('Waiting for Apple ID/password input in terminal prompt...'); + try { + const userAuth = await loginWithUserCredentialsAsync( + promptTerminalInput, + promptTerminalPassword, + promptTerminalConfirm, + ); + const context = getRequestContext(userAuth); + + setPhase('syncing'); + setAuthStatusMessage('Authenticated. Syncing Apple capabilities...'); + return await syncCapabilities(context, bundleId, appGroupId); + } catch (error) { + const reason = + error instanceof Error ? getAppleApiErrorMessage(error) : 'Authentication was not completed'; + if (isBundleIdMismatchReason(reason)) { + throw new Error(reason); + } + setPortalSkipReason(reason); + setAuthStatusMessage(`Portal sync skipped: ${reason}`); + // Portal sync is best-effort: login cancelled, auth failed, or API error + // all result in skipping portal sync and continuing with entitlements. 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 { @@ -197,15 +231,19 @@ async function runSetup( 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 })), + // Phase 2: Sync with Apple Developer Portal via Apple ID login + const syncResult = await syncWithPortal( + bundleId, + appGroupId, + (phase) => setState((s) => ({ ...s, phase })), + (message) => setState((s) => ({ ...s, authStatusMessage: message })), + (message) => setState((s) => ({ ...s, portalSkipReason: message })), ); if (syncResult) { setState((s) => ({ ...s, portalResult: syncResult })); result.portalSync = syncResult; - } - if (options.skipPortal) { + } else { + setState((s) => ({ ...s, portalSkipped: true })); result.changeSummary?.skippedChecks.push('Apple Developer Portal sync'); } @@ -286,6 +324,9 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = phase: 'analyzing', projectInfo: null, portalResult: null, + portalSkipped: false, + portalSkipReason: null, + authStatusMessage: '', mainTargetEntitlementsLink: null, updatedFiles: [], errorMessage: '', @@ -296,6 +337,9 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = phase, projectInfo, portalResult, + portalSkipped, + portalSkipReason, + authStatusMessage, mainTargetEntitlementsLink, updatedFiles, errorMessage, @@ -343,7 +387,15 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = {phase === 'authenticating' && ( - + + {authStatusMessage ? ( + + • {authStatusMessage} + + ) : null} + + • Press Ctrl+C to cancel + )} @@ -360,7 +412,11 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = {phase === 'updating_entitlements' && ( - + )} @@ -370,9 +426,10 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = )} @@ -404,10 +461,20 @@ const ProjectInfoStatus: React.FC<{ projectInfo: IosProjectInfo | null }> = ({ p const PortalSyncStatus: React.FC<{ portalResult: CapabilitySyncResult | null; - skipPortal?: boolean; -}> = ({ portalResult, skipPortal }) => { - if (skipPortal) { - return ; + portalSkipped: boolean; + portalSkipReason: string | null; +}> = ({ portalResult, portalSkipped, portalSkipReason }) => { + if (portalSkipped) { + return ( + + + {portalSkipReason ? ( + + Reason: {portalSkipReason} + + ) : null} + + ); } if (!portalResult) return null; return ( @@ -430,10 +497,18 @@ const PortalSyncStatus: React.FC<{ const CompletePhase: React.FC<{ projectInfo: IosProjectInfo | null; portalResult: CapabilitySyncResult | null; + portalSkipped: boolean; + portalSkipReason: string | null; mainTargetEntitlementsLink: MainTargetEntitlementsLinkResult | null; updatedFiles: string[]; - skipPortal?: boolean; -}> = ({ projectInfo, portalResult, mainTargetEntitlementsLink, updatedFiles, skipPortal }) => { +}> = ({ + projectInfo, + portalResult, + portalSkipped, + portalSkipReason, + mainTargetEntitlementsLink, + updatedFiles, +}) => { const nextSteps: string[] = []; if (mainTargetEntitlementsLink?.success) { @@ -454,19 +529,16 @@ const CompletePhase: React.FC<{ ); } - if (skipPortal) { + if (portalSkipped) { nextSteps.push( 'If Xcode cannot enable capabilities (team/permission issue), use Apple Developer Portal: https://developer.apple.com/account/resources/identifiers/list -> Identifiers -> your App ID -> enable Push Notifications/App Groups -> Save.', ); - nextSteps.push( - 'If signing/profile errors remain, open https://developer.apple.com/account/resources/profiles/list and regenerate profiles. With Automatic Signing, Xcode may refresh them automatically.', - ); - } else { - nextSteps.push( - 'If signing/profile errors remain, open https://developer.apple.com/account/resources/profiles/list and regenerate profiles for this App ID (or let Automatic Signing refresh them).', - ); } + nextSteps.push( + 'If signing/profile errors remain, open https://developer.apple.com/account/resources/profiles/list and regenerate profiles for this App ID (or let Automatic Signing refresh them).', + ); + return ( @@ -487,8 +559,15 @@ const CompletePhase: React.FC<{ )} - {skipPortal && ( - + {portalSkipped && ( + + + {portalSkipReason ? ( + + Reason: {portalSkipReason} + + ) : null} + )} diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx index 7dafb77..8a95126 100644 --- a/src/ui/components/FirebaseConfigFilesSetup.tsx +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -69,21 +69,74 @@ function DetectingPhase(): React.ReactElement { function StatusPhase({ result, + identifierMismatch, + expectedIdentifiers, onContinue, + onCreateAndroidForCurrentPackage, + onCreateIosForCurrentBundle, onCancel, }: { result: FirebaseDetectionResult; + identifierMismatch?: { ios: boolean; android: boolean }; + expectedIdentifiers?: { iosBundleId?: string; androidPackageName?: string }; onContinue: () => void; + onCreateAndroidForCurrentPackage?: () => void; + onCreateIosForCurrentBundle?: () => void; onCancel: () => void; }): React.ReactElement { + const canCreateAndroidForCurrentPackage = Boolean( + onCreateAndroidForCurrentPackage && + identifierMismatch?.android && + expectedIdentifiers?.androidPackageName, + ); + const canCreateIosForCurrentBundle = Boolean( + onCreateIosForCurrentBundle && identifierMismatch?.ios && expectedIdentifiers?.iosBundleId, + ); + const hasCreateAction = canCreateAndroidForCurrentPackage || canCreateIosForCurrentBundle; + useInput((_input, key) => { - if (key.return) { + if (!hasCreateAction && key.return) { onContinue(); } }); useCancelInput(onCancel); + const hasMismatch = identifierMismatch?.ios || identifierMismatch?.android; + const detectedIosBundleId = + result.ios?.content && 'BUNDLE_ID' in result.ios.content + ? (result.ios.content as { BUNDLE_ID?: string }).BUNDLE_ID + : undefined; + const detectedAndroidPackage = + result.android?.content && 'client' in result.android.content + ? ( + result.android.content as { + client?: Array<{ client_info?: { android_client_info?: { package_name?: string } } }>; + } + ).client + ?.map((client) => client.client_info?.android_client_info?.package_name) + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join(', ') + : undefined; + + const mismatchActionItems: Array<{ + label: string; + value: 'download' | 'create_android' | 'create_ios' | 'cancel'; + }> = [{ label: '⬇ Download correct files', value: 'download' }]; + if (canCreateAndroidForCurrentPackage) { + mismatchActionItems.push({ + label: `➕ Create Android app for current package (${expectedIdentifiers?.androidPackageName})`, + value: 'create_android', + }); + } + if (canCreateIosForCurrentBundle) { + mismatchActionItems.push({ + label: `➕ Create iOS app for current bundle ID (${expectedIdentifiers?.iosBundleId})`, + value: 'create_ios', + }); + } + mismatchActionItems.push({ label: '← Back', value: 'cancel' }); + return ( + {identifierMismatch?.ios && expectedIdentifiers?.iosBundleId && ( + + + ⚠ iOS Bundle ID mismatch + + + Expected: {expectedIdentifiers.iosBundleId} + Found: {detectedIosBundleId ?? 'unknown'} + + + )} + {identifierMismatch?.android && expectedIdentifiers?.androidPackageName && ( + + + ⚠ Android package name mismatch + + + Expected: {expectedIdentifiers.androidPackageName} + Found: {detectedAndroidPackage ?? 'unknown'} + + + )} - Press Enter to download required files, Esc/Ctrl+C to cancel + {hasCreateAction ? ( + + Choose how to resolve identifier mismatch: + { + if (item.value === 'download') { + onContinue(); + return; + } + if (item.value === 'create_android') { + onCreateAndroidForCurrentPackage?.(); + return; + } + if (item.value === 'create_ios') { + onCreateIosForCurrentBundle?.(); + return; + } + onCancel(); + }} + /> + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + ) : ( + + {hasMismatch + ? 'Press Enter to download correct files, Esc/Ctrl+C to cancel' + : 'Press Enter to download required files, Esc/Ctrl+C to cancel'} + + )} ); @@ -341,14 +445,16 @@ function NoAppsFoundPhase({ function CreateAppInputPhase({ platform, + defaultIdentifier, onSubmit, onCancel, }: { platform: 'android' | 'ios'; + defaultIdentifier?: string; onSubmit: (identifier: string, displayName?: string) => void; onCancel: () => void; }): React.ReactElement { - const [identifier, setIdentifier] = useState(''); + const [identifier, setIdentifier] = useState(defaultIdentifier ?? ''); const [displayName, setDisplayName] = useState(''); const [stage, setStage] = useState<'identifier' | 'display_name'>('identifier'); diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index 28d13fa..40ca2e9 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -48,6 +48,8 @@ import { FirebaseDownloader, type FirebaseProject, FirebaseService, + type GoogleServiceInfoPlist, + type GoogleServicesJson, type IosApp, isOAuthConfigured, parseBase64ServiceAccountJson, @@ -232,6 +234,7 @@ interface FirebaseConfigState { downloadingPlatform: 'android' | 'ios' | 'both'; noAppsContext: FirebaseConfigNoAppsContext | null; creatingAppPlatform: 'android' | 'ios'; + postAuthAction: 'none' | 'create_ios_for_current_bundle' | 'create_android_for_current_package'; error: string | null; } @@ -278,7 +281,7 @@ async function buildNotificationExtensionContext( projectPath: string, iosBundleId: string | undefined, iosAppGroupId: string | undefined, - firebaseProjectId: string | undefined, + clixProjectId: string | undefined, cachedContext: IosGuidedContextCache | null, ): Promise { if (cachedContext?.projectId) { @@ -300,9 +303,9 @@ async function buildNotificationExtensionContext( const entitlementsPath = analysis.project.entitlementsFiles[0] || getEntitlementsPath(iosDir, targetName); const appGroupId = iosAppGroupId || generateAppGroupId(bundleId); - const projectId = firebaseProjectId || ''; + const projectId = clixProjectId || ''; if (!projectId) { - throw new Error('Firebase project ID is required for Notification Service Extension setup.'); + throw new Error('Clix Project ID is required for Notification Service Extension setup.'); } return { @@ -623,6 +626,54 @@ function getTaskMessages( return chatMessages.slice(startIndex); } +interface FirebaseConfigIdentifierMismatch { + ios: boolean; + android: boolean; +} + +function extractAndroidPackageNames(detection: FirebaseDetectionResult | null): string[] { + if (!detection?.android?.content || !('client' in detection.android.content)) { + return []; + } + + const clients = (detection.android.content as GoogleServicesJson).client ?? []; + return clients + .map((client) => client.client_info?.android_client_info?.package_name) + .filter((value): value is string => typeof value === 'string' && value.length > 0); +} + +function getFirebaseConfigIdentifierMismatch( + detection: FirebaseDetectionResult | null, + context: PreparationContext | null, +): FirebaseConfigIdentifierMismatch { + if (!detection || !context) { + return { ios: false, android: false }; + } + + let iosMismatch = false; + if (detection.ios?.valid && detection.ios.content && 'BUNDLE_ID' in detection.ios.content) { + const detectedBundleId = (detection.ios.content as GoogleServiceInfoPlist).BUNDLE_ID; + if (detectedBundleId && context.ios.bundleId && detectedBundleId !== context.ios.bundleId) { + iosMismatch = true; + } + } + + let androidMismatch = false; + if (detection.android?.valid) { + const detectedPackages = extractAndroidPackageNames(detection); + const expectedPackage = context.firebase.actualAndroidPackageName; + if ( + expectedPackage && + detectedPackages.length > 0 && + !detectedPackages.includes(expectedPackage) + ) { + androidMismatch = true; + } + } + + return { ios: iosMismatch, android: androidMismatch }; +} + function getInitialLeafTaskId(taskId: InstallTaskId): InstallLeafTaskId { switch (taskId) { case 'firebase_config_files': @@ -656,6 +707,7 @@ function createInitialFirebaseConfigState(): FirebaseConfigState { downloadingPlatform: 'both', noAppsContext: null, creatingAppPlatform: 'android', + postAuthAction: 'none', error: null, }; } @@ -927,10 +979,10 @@ function validateStartTaskOverride({ }; } - if (effectiveStartTaskId === 'notification_service_extension' && !context.firebase.projectId) { + if (effectiveStartTaskId === 'notification_service_extension' && !context.config.project.id) { return { taskId: null, - note: 'Task override requires a detected Firebase project ID. Configure Firebase Configuration Files first.', + note: 'Task override requires a linked Clix Project ID. Run setup/login first.', }; } @@ -1177,19 +1229,22 @@ export function InstallPreparationUI({ activeLeafTaskId === 'install_skill_running', }); - const getFirebaseConfigNeeds = useCallback((detection: FirebaseDetectionResult | null) => { - const platform = detection?.platform ?? 'unknown'; - const needsAndroid = firebaseConfigNeedsAndroid(platform); - const needsIos = firebaseConfigNeedsIos(platform); - const needsAndroidConfig = needsAndroid && !detection?.android?.valid; - const needsIosConfig = needsIos && !detection?.ios?.valid; - const forceDownload = platform === 'unknown'; + const getFirebaseConfigNeeds = useCallback( + (detection: FirebaseDetectionResult | null, mismatch?: FirebaseConfigIdentifierMismatch) => { + const platform = detection?.platform ?? 'unknown'; + const needsAndroid = firebaseConfigNeedsAndroid(platform); + const needsIos = firebaseConfigNeedsIos(platform); + const needsAndroidConfig = needsAndroid && (!detection?.android?.valid || mismatch?.android); + const needsIosConfig = needsIos && (!detection?.ios?.valid || mismatch?.ios); + const forceDownload = platform === 'unknown'; - return { - shouldFetchAndroid: needsAndroidConfig || (forceDownload && needsAndroid), - shouldFetchIos: needsIosConfig || (forceDownload && needsIos), - }; - }, []); + return { + shouldFetchAndroid: needsAndroidConfig || (forceDownload && needsAndroid), + shouldFetchIos: needsIosConfig || (forceDownload && needsIos), + }; + }, + [], + ); const startFirebaseConfigDownload = useCallback( async (project: FirebaseProject, androidApp: AndroidApp | null, iosApp: IosApp | null) => { @@ -1233,7 +1288,12 @@ export function InstallPreparationUI({ setFirebaseConfigState((prev) => ({ ...prev, service, detection: newDetection })); if (hasValidFirebaseConfigTaskFiles(newDetection)) { - await applyTaskCompletion(); + const mismatch = getFirebaseConfigIdentifierMismatch(newDetection, context); + if (!mismatch.ios && !mismatch.android) { + await applyTaskCompletion(); + return; + } + setActiveLeafTaskId('firebase_config_status'); return; } @@ -1252,6 +1312,7 @@ export function InstallPreparationUI({ }, [ applyTaskCompletion, + context, firebaseConfigDownloader, firebaseConfigState.projectType, firebaseConfigState.service, @@ -1263,8 +1324,13 @@ export function InstallPreparationUI({ async (project: FirebaseProject) => { setFirebaseConfigState((prev) => ({ ...prev, selectedProject: project, error: null })); + const configMismatch = getFirebaseConfigIdentifierMismatch( + firebaseConfigState.detection, + context, + ); const { shouldFetchAndroid, shouldFetchIos } = getFirebaseConfigNeeds( firebaseConfigState.detection, + configMismatch, ); if (!shouldFetchAndroid && !shouldFetchIos) { await applyTaskCompletion(); @@ -1340,6 +1406,7 @@ export function InstallPreparationUI({ }, [ applyTaskCompletion, + context, firebaseConfigDownloader, firebaseConfigState.detection, getFirebaseConfigNeeds, @@ -1357,7 +1424,10 @@ export function InstallPreparationUI({ } setFirebaseConfigState((prev) => ({ ...prev, selectedAndroidApp: app })); - const { shouldFetchIos } = getFirebaseConfigNeeds(firebaseConfigState.detection); + const { shouldFetchIos } = getFirebaseConfigNeeds( + firebaseConfigState.detection, + getFirebaseConfigIdentifierMismatch(firebaseConfigState.detection, context), + ); if (!shouldFetchIos) { await startFirebaseConfigDownload(project, app, null); @@ -1384,6 +1454,7 @@ export function InstallPreparationUI({ } }, [ + context, firebaseConfigDownloader, firebaseConfigState.detection, firebaseConfigState.selectedProject, @@ -1393,15 +1464,26 @@ export function InstallPreparationUI({ ); const handleFirebaseConfigCreateApp = useCallback( - async (platform: 'android' | 'ios', identifier: string, displayName?: string) => { - const selectedProject = firebaseConfigState.selectedProject; + async ( + platform: 'android' | 'ios', + identifier: string, + displayName?: string, + projectOverride?: FirebaseProject, + ) => { + const selectedProject = projectOverride ?? firebaseConfigState.selectedProject; if (!selectedProject) { setFirebaseConfigState((prev) => ({ ...prev, error: 'No Firebase project selected.' })); setActiveLeafTaskId('firebase_config_error'); return; } - setFirebaseConfigState((prev) => ({ ...prev, creatingAppPlatform: platform })); + const normalizedIdentifier = identifier.trim(); + setFirebaseConfigState((prev) => ({ + ...prev, + selectedProject, + creatingAppPlatform: platform, + error: null, + })); setActiveLeafTaskId('firebase_config_creating_app'); try { @@ -1409,7 +1491,7 @@ export function InstallPreparationUI({ const androidApp = await firebaseConfigDownloader.createAndroidApp( selectedProject.projectId, { - packageName: identifier, + packageName: normalizedIdentifier, displayName, }, ); @@ -1420,7 +1502,26 @@ export function InstallPreparationUI({ selectedAndroidApp: androidApp, })); - const { shouldFetchIos } = getFirebaseConfigNeeds(firebaseConfigState.detection); + const detectedProjectType = firebaseConfigState.projectType; + if (detectedProjectType) { + const paths = firebaseConfigDownloader.getExpectedSavePaths( + projectPath, + detectedProjectType, + ); + if (paths.android) { + // Immediately download config for the newly created Android app. + await firebaseConfigDownloader.downloadAndroidConfig( + selectedProject.projectId, + androidApp.appId, + paths.android, + ); + } + } + + const { shouldFetchIos } = getFirebaseConfigNeeds( + firebaseConfigState.detection, + getFirebaseConfigIdentifierMismatch(firebaseConfigState.detection, context), + ); if (!shouldFetchIos) { await startFirebaseConfigDownload(selectedProject, androidApp, null); return; @@ -1452,10 +1553,22 @@ export function InstallPreparationUI({ return; } - const iosApp = await firebaseConfigDownloader.createIosApp(selectedProject.projectId, { - bundleId: identifier, - displayName, - }); + const iosApps = await firebaseConfigDownloader.listIosApps(selectedProject.projectId); + const existingIosApp = firebaseConfigDownloader.findAppByBundleId( + iosApps, + normalizedIdentifier, + ); + const iosApp = + existingIosApp || + (await firebaseConfigDownloader.createIosApp(selectedProject.projectId, { + bundleId: normalizedIdentifier, + displayName, + })); + + setFirebaseConfigState((prev) => ({ + ...prev, + iosApps: existingIosApp ? iosApps : [...iosApps, iosApp], + })); await startFirebaseConfigDownload( selectedProject, @@ -1471,11 +1584,14 @@ export function InstallPreparationUI({ } }, [ + context, firebaseConfigDownloader, firebaseConfigState.detection, + firebaseConfigState.projectType, firebaseConfigState.selectedAndroidApp, firebaseConfigState.selectedProject, getFirebaseConfigNeeds, + projectPath, startFirebaseConfigDownload, ], ); @@ -1510,8 +1626,11 @@ export function InstallPreparationUI({ })); if (hasValidFirebaseConfigTaskFiles(detection)) { - await applyTaskCompletion(); - return; + const mismatch = getFirebaseConfigIdentifierMismatch(detection, context); + if (!mismatch.ios && !mismatch.android) { + await applyTaskCompletion(); + return; + } } setActiveLeafTaskId('firebase_config_status'); @@ -1533,14 +1652,7 @@ export function InstallPreparationUI({ return () => { cancelled = true; }; - }, [ - activeLeafTaskId, - activeTaskId, - applyTaskCompletion, - context?.projectType, - phase, - projectPath, - ]); + }, [activeLeafTaskId, activeTaskId, applyTaskCompletion, context, phase, projectPath]); useEffect(() => { if ( @@ -1586,6 +1698,83 @@ export function InstallPreparationUI({ return; } + if (firebaseConfigState.postAuthAction !== 'none') { + const isCreateIosAction = + firebaseConfigState.postAuthAction === 'create_ios_for_current_bundle'; + const targetIdentifier = isCreateIosAction + ? context?.ios.bundleId + : context?.firebase.actualAndroidPackageName; + if (!targetIdentifier) { + setFirebaseConfigState((prev) => ({ + ...prev, + postAuthAction: 'none', + error: isCreateIosAction + ? 'Current iOS bundle ID could not be detected.' + : 'Current Android package name could not be detected.', + })); + setActiveLeafTaskId('firebase_config_error'); + return; + } + + const projectIdResolution = firebaseConfigState.detection + ? resolveFirebaseProjectIdFromDetection(firebaseConfigState.detection) + : { projectId: null }; + if (projectIdResolution.mismatchMessage) { + setFirebaseConfigState((prev) => ({ + ...prev, + postAuthAction: 'none', + error: + projectIdResolution.mismatchMessage || + 'Firebase config files use different project IDs.', + })); + setActiveLeafTaskId('firebase_config_error'); + return; + } + + let targetProject: FirebaseProject | null = null; + if (projectIdResolution.projectId) { + targetProject = + projects.find((project) => project.projectId === projectIdResolution.projectId) || + null; + if (!targetProject) { + setFirebaseConfigState((prev) => ({ + ...prev, + postAuthAction: 'none', + error: `Firebase project "${projectIdResolution.projectId}" from current config was not found in your account.`, + })); + setActiveLeafTaskId('firebase_config_error'); + return; + } + } else if (projects.length === 1) { + targetProject = projects[0]; + } + + if (!targetProject) { + setFirebaseConfigState((prev) => ({ + ...prev, + projects, + postAuthAction: 'none', + })); + setActiveLeafTaskId('firebase_config_select_project'); + return; + } + + setFirebaseConfigState((prev) => ({ + ...prev, + projects, + selectedProject: targetProject, + postAuthAction: 'none', + error: null, + })); + await handleFirebaseConfigCreateApp( + isCreateIosAction ? 'ios' : 'android', + targetIdentifier, + undefined, + targetProject, + ); + return; + } + setFirebaseConfigState((prev) => ({ ...prev, projects })); if (projects.length === 1) { await handleFirebaseConfigProjectSelect(projects[0]); @@ -1617,7 +1806,12 @@ export function InstallPreparationUI({ }, [ activeLeafTaskId, activeTaskId, + context?.ios.bundleId, + context?.firebase.actualAndroidPackageName, firebaseConfigDownloader, + firebaseConfigState.detection, + firebaseConfigState.postAuthAction, + handleFirebaseConfigCreateApp, handleFirebaseConfigProjectSelect, phase, ]); @@ -1955,7 +2149,7 @@ export function InstallPreparationUI({ projectPath, context?.ios.bundleId, context?.ios.appGroupId, - context?.firebase.projectId, + context?.config.project.id, guidedContextCache, ); @@ -1985,7 +2179,7 @@ export function InstallPreparationUI({ activeTaskId, context?.ios.appGroupId, context?.ios.bundleId, - context?.firebase.projectId, + context?.config.project.id, guidedContextCache, handleTaskBackToStatus, phase, @@ -2396,6 +2590,13 @@ export function InstallPreparationUI({ case 'firebase_config_files': { const detection = firebaseConfigState.detection; const selectedProject = firebaseConfigState.selectedProject; + const identifierMismatch = getFirebaseConfigIdentifierMismatch(detection, context); + const canCreateAndroidForCurrentPackage = + Boolean(identifierMismatch.android) && Boolean(context?.firebase.actualAndroidPackageName); + const canCreateIosForCurrentBundle = + Boolean(identifierMismatch.ios) && + !identifierMismatch.android && + Boolean(context?.ios.bundleId); return ( @@ -2409,7 +2610,37 @@ export function InstallPreparationUI({ {activeLeafTaskId === 'firebase_config_status' && detection && ( setActiveLeafTaskId('firebase_config_authenticating')} + identifierMismatch={identifierMismatch} + expectedIdentifiers={{ + iosBundleId: context?.ios.bundleId, + androidPackageName: context?.firebase.actualAndroidPackageName, + }} + onContinue={() => { + setFirebaseConfigState((prev) => ({ ...prev, postAuthAction: 'none' })); + setActiveLeafTaskId('firebase_config_authenticating'); + }} + onCreateAndroidForCurrentPackage={ + canCreateAndroidForCurrentPackage + ? () => { + setFirebaseConfigState((prev) => ({ + ...prev, + postAuthAction: 'create_android_for_current_package', + })); + setActiveLeafTaskId('firebase_config_authenticating'); + } + : undefined + } + onCreateIosForCurrentBundle={ + canCreateIosForCurrentBundle + ? () => { + setFirebaseConfigState((prev) => ({ + ...prev, + postAuthAction: 'create_ios_for_current_bundle', + })); + setActiveLeafTaskId('firebase_config_authenticating'); + } + : undefined + } onCancel={() => handleTaskBackToStatus()} /> )} @@ -2481,6 +2712,7 @@ export function InstallPreparationUI({ {activeLeafTaskId === 'firebase_config_create_android_app' && ( { void handleFirebaseConfigCreateApp('android', identifier, displayName); }} @@ -2491,6 +2723,7 @@ export function InstallPreparationUI({ {activeLeafTaskId === 'firebase_config_create_ios_app' && ( { void handleFirebaseConfigCreateApp('ios', identifier, displayName); }} @@ -2735,7 +2968,7 @@ export function InstallPreparationUI({ appName: result.agentContext.appName, iosDir: result.agentContext.iosDir, xcodeprojPath: result.agentContext.projectPath, - projectId: context.firebase.projectId || '', + projectId: context.config.project.id || '', entitlementsPath: result.agentContext.entitlementsPath, }); } diff --git a/src/ui/components/ios-setup/IosEntitlementsTask.tsx b/src/ui/components/ios-setup/IosEntitlementsTask.tsx index d8b9c72..f4191b5 100644 --- a/src/ui/components/ios-setup/IosEntitlementsTask.tsx +++ b/src/ui/components/ios-setup/IosEntitlementsTask.tsx @@ -8,5 +8,5 @@ interface IosEntitlementsTaskProps { } export const IosEntitlementsTask: React.FC = ({ onComplete }) => { - return ; + return ; }; From 36ae7214be38be840c60a43cc77b2886e62ec0cf Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Sun, 22 Feb 2026 22:20:36 -0800 Subject: [PATCH 20/30] fix(cli): exit immediately after handoff completion Ensure clix terminates with the handoff process exit code to prevent requiring extra Ctrl+C after agent/CLI handoff completes. Also simplify Firebase auth UI by removing the duplicated Direct URL section and showing a clickable full URL inline. --- src/commands/__tests__/mcp.test.ts | 6 +++++- src/commands/__tests__/skills.test.ts | 6 +++++- src/commands/mcp.ts | 6 +++--- src/commands/skill/index.tsx | 4 +--- src/commands/skills.ts | 6 +++--- src/ui/components/FirebaseConfigFilesSetup.tsx | 8 +------- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/commands/__tests__/mcp.test.ts b/src/commands/__tests__/mcp.test.ts index 14d65e5..3c60a05 100644 --- a/src/commands/__tests__/mcp.test.ts +++ b/src/commands/__tests__/mcp.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; import type { CommandHandoffInvocation } from '@/lib/services/agent-handoff'; const runCommandHandoffMock = mock(async (_invocation: CommandHandoffInvocation) => 0); +const exitProcessMock = mock((_code: number) => {}); import { mcpCommand } from '../mcp'; @@ -9,10 +10,11 @@ describe('mcpCommand', () => { beforeEach(() => { runCommandHandoffMock.mockReset(); runCommandHandoffMock.mockResolvedValue(0); + exitProcessMock.mockReset(); }); test('hands off with fixed add-mcp arguments', async () => { - await mcpCommand({ runHandoff: runCommandHandoffMock }); + await mcpCommand({ runHandoff: runCommandHandoffMock, exitProcess: exitProcessMock }); expect(runCommandHandoffMock).toHaveBeenCalledTimes(1); const invocation = runCommandHandoffMock.mock.calls[0]?.[0]; @@ -30,5 +32,7 @@ describe('mcpCommand', () => { '--name', 'clix', ]); + expect(exitProcessMock).toHaveBeenCalledTimes(1); + expect(exitProcessMock).toHaveBeenCalledWith(0); }); }); diff --git a/src/commands/__tests__/skills.test.ts b/src/commands/__tests__/skills.test.ts index 98cd6a2..a5e51ef 100644 --- a/src/commands/__tests__/skills.test.ts +++ b/src/commands/__tests__/skills.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; import type { CommandHandoffInvocation } from '@/lib/services/agent-handoff'; const runCommandHandoffMock = mock(async (_invocation: CommandHandoffInvocation) => 0); +const exitProcessMock = mock((_code: number) => {}); import { skillsCommand } from '../skills'; @@ -9,10 +10,11 @@ describe('skillsCommand', () => { beforeEach(() => { runCommandHandoffMock.mockReset(); runCommandHandoffMock.mockResolvedValue(0); + exitProcessMock.mockReset(); }); test('hands off to npx skills add clix-so/skills', async () => { - await skillsCommand({ runHandoff: runCommandHandoffMock }); + await skillsCommand({ runHandoff: runCommandHandoffMock, exitProcess: exitProcessMock }); expect(runCommandHandoffMock).toHaveBeenCalledTimes(1); expect(runCommandHandoffMock).toHaveBeenCalledWith({ @@ -21,5 +23,7 @@ describe('skillsCommand', () => { workingDirectory: process.cwd(), displayName: 'Skills CLI', }); + expect(exitProcessMock).toHaveBeenCalledTimes(1); + expect(exitProcessMock).toHaveBeenCalledWith(0); }); }); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 7a86d82..5513d30 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -6,6 +6,7 @@ const MCP_SERVER_NAME = 'clix'; interface MCPCommandDependencies { runHandoff?: typeof runCommandHandoff; + exitProcess?: (code: number) => void; } function buildMcpHandoffArgs(): string[] { @@ -14,6 +15,7 @@ function buildMcpHandoffArgs(): string[] { export async function mcpCommand(dependencies: MCPCommandDependencies = {}): Promise { const runHandoff = dependencies.runHandoff ?? runCommandHandoff; + const exitProcess = dependencies.exitProcess ?? ((code: number) => process.exit(code)); console.log('Adding Clix MCP Server...'); @@ -30,7 +32,5 @@ export async function mcpCommand(dependencies: MCPCommandDependencies = {}): Pro throw new Error(`Failed to launch add-mcp: ${message}`); } - if (exitCode !== 0) { - process.exit(exitCode); - } + exitProcess(exitCode); } diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index ffb9e29..1e077ad 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -271,7 +271,5 @@ export async function skillCommand(options: SkillCommandOptions): Promise throw new Error(`Failed to launch ${agent.displayName}: ${message}`); } - if (exitCode !== 0) { - process.exit(exitCode); - } + process.exit(exitCode); } diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 480eed7..2c19dbf 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -4,10 +4,12 @@ const DEFAULT_SKILLS_REPOSITORY = 'clix-so/skills'; interface SkillsCommandDependencies { runHandoff?: typeof runCommandHandoff; + exitProcess?: (code: number) => void; } export async function skillsCommand(dependencies: SkillsCommandDependencies = {}): Promise { const runHandoff = dependencies.runHandoff ?? runCommandHandoff; + const exitProcess = dependencies.exitProcess ?? ((code: number) => process.exit(code)); console.log('Adding Clix Skills...'); @@ -24,7 +26,5 @@ export async function skillsCommand(dependencies: SkillsCommandDependencies = {} throw new Error(`Failed to launch Skills CLI: ${message}`); } - if (exitCode !== 0) { - process.exit(exitCode); - } + exitProcess(exitCode); } diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx index 8a95126..74bb165 100644 --- a/src/ui/components/FirebaseConfigFilesSetup.tsx +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -213,7 +213,7 @@ function AuthenticatingPhase({ authUrl?: string | null; }): React.ReactElement { useCancelInput(onCancel); - const reopenLink = authUrl ? formatTerminalHyperlink(authUrl, 'Open authentication URL') : null; + const reopenLink = authUrl ? formatTerminalHyperlink(authUrl, authUrl) : null; return ( @@ -247,12 +247,6 @@ function AuthenticatingPhase({ Press Esc/Ctrl+C to cancel - {authUrl ? ( - - Direct URL: - {authUrl} - - ) : null} ); } From 5fd92c0da8c927225e81ee3454b3ef24a505133c Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 10:42:27 -0800 Subject: [PATCH 21/30] refactor: address branch review findings across 27 files Critical fixes: - Add null guard in waitForOperation response handling - Reset installSkillRunStartedRef on retry to prevent stuck state - Classify invalid_grant vs transient token refresh errors correctly Code deduplication: - Extract shared firebase-detection-utils module - Export createDefaultOpenCodeEnv from opencode-executor for reuse - Move extractTextDelta to BaseExecutor (shared by Claude/Cursor) Consistency improvements: - Add streamMode to result path text emissions - Use AgentError instead of generic Error in command modules - Add useCancelInput to IosSetupUI authenticating phase - Wrap execve call with try/catch fallback in agent-handoff - Prevent double addTargetDependency in pbxproj-modifier Dead code removal: - Remove GeminiCLIInitEvent, OpenCodeCLISessionEvent dead types - Remove unused allowedTools from ExecuteOptions - Remove SkillMetadata and related dead exports - Inline single-use buildMcpHandoffArgs helper - Clean up dead test fixtures and cancelled phase --- .gitignore | 3 +- plan.md | 133 ------------------ scripts/embed-skills.ts | 30 ---- src/commands/mcp.ts | 9 +- src/commands/skills.ts | 3 +- src/lib/__tests__/embedded-skills.test.ts | 9 -- src/lib/__tests__/test-utils/fixtures.ts | 58 -------- src/lib/constants/system-prompt.ts | 6 - src/lib/embedded-skills.ts | 30 ---- src/lib/executor.ts | 1 - .../__tests__/gemini-executor.test.ts | 13 -- .../__tests__/opencode-executor.test.ts | 12 -- src/lib/executors/base-executor.ts | 16 +++ src/lib/executors/claude-executor.ts | 22 +-- src/lib/executors/cursor-executor.ts | 21 +-- src/lib/executors/gemini-executor.ts | 2 - src/lib/executors/opencode-executor.ts | 21 +-- src/lib/executors/types.ts | 15 -- src/lib/ios/pbxproj-modifier.ts | 47 ++++--- src/lib/services/agent-handoff.ts | 12 +- src/lib/services/firebase/api/firebase-api.ts | 3 + .../services/firebase/oauth/auth-client.ts | 5 +- src/ui/IosSetupUI.tsx | 10 +- .../components/FirebaseConfigFilesSetup.tsx | 38 ++--- .../FirebaseServiceAccountSetup.tsx | 36 +---- src/ui/components/InstallPreparationUI.tsx | 11 +- src/ui/components/firebase-detection-utils.ts | 25 ++++ 27 files changed, 120 insertions(+), 471 deletions(-) delete mode 100644 plan.md create mode 100644 src/ui/components/firebase-detection-utils.ts diff --git a/.gitignore b/.gitignore index 209af4d..6ac564d 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,5 @@ android/app/google-services.json .omc .ralph-tui -llm-council \ No newline at end of file +llm-council +plan.md \ No newline at end of file diff --git a/plan.md b/plan.md deleted file mode 100644 index bef473d..0000000 --- a/plan.md +++ /dev/null @@ -1,133 +0,0 @@ -# feat/refactor-firebase-apis 브랜치 작업 현황 리뷰 - -## Context - -이 브랜치는 Firebase API 클라이언트를 googleapis 라이브러리로 마이그레이션하고, Service Account 관리 방식을 변경하며, Sender Config API 연동과 FirebaseWizard 상태 머신 리팩터링을 포함하는 대규모 작업입니다. - ---- - -## 완료된 작업 (커밋 완료) - -| # | 커밋 | 내용 | -|---|------|------| -| 1 | `acc35b0` | feat(config): 자동 프로젝트 타입 감지 | -| 2 | `5e5ad31` | feat: 프로젝트 로컬 `.clix/` 스토리지 마이그레이션 | -| 3 | `354615f` | feat(firebase): SA 관리 및 프로젝트 생성 | -| 4 | `1c18441` | refactor(firebase): REST API → googleapis 라이브러리 전환 | -| 5 | `4e604c5` | refactor(config): V1/V2 버전 관리 제거로 프로젝트 설정 단순화 | - ---- - -## 완료된 작업 (아직 커밋 안 됨 — working tree) - -### 1. Firebase API googleapis 마이그레이션 (추가 정리) -- **파일**: `firebase-api.ts`, `types.ts`, `index.ts`, `downloader.ts` -- IAM API 클라이언트 완전 삭제 (`iam-api.ts` 파일 삭제) -- `@googleapis/iam` 의존성 제거 -- Service Account 생성/키 발급 메서드 제거 (사용자가 Firebase Console에서 직접 다운로드) -- API 호출마다 `debug.log`에 에러 로깅 추가 - -### 2. OAuth 스코프 단순화 + 디버깅 인프라 -- **파일**: `oauth/config.ts`, `oauth/auth-client.ts`, `logger.ts`, `oauth.ts` -- OAuth 스코프: firebase + iam + cloud-platform → **firebase만** -- `.clix/debug.log` 파일 기반 OAuth 디버그 로깅 추가 -- `invalid_grant` 에러 시 토큰 자동 클리어 + 재인증 시그널 -- OAuth 콜백 HTML 스타일 업데이트 - -### 3. Internal API — Sender Config 연동 -- **파일**: `api/types.ts`, `api/internal-client.ts`, `api/index.ts` -- `AppPushSenderConfig`, `SenderConfig` 타입 추가 -- `getProject()`, `createOrUpdateSenderConfig()` API 메서드 추가 -- 429/5xx/timeout에 대한 지수 백오프 재시도 로직 - -### 4. Firebase 감지 로직 수정 -- **파일**: `preparation.ts`, `preparation.test.ts` -- `checkFirebaseStatus()`가 캐시된 `.clix/config.jsonc`만 보지 않고 항상 실제 파일 탐지 수행 -- 파일에 프로젝트 ID가 없을 때 캐시된 projectId로 폴백 - -### 5. FirebaseWizard 상태 머신 리팩터링 -- **파일**: `firebase-wizard-transitions.ts` (신규), `FirebaseWizard.tsx`, `__tests__/firebase-wizard-transitions.test.ts` (신규) -- `PHASE_TRANSITIONS` 중앙 집중 맵 + `transition()` 검증 함수 추출 -- 모든 `setPhase()` 호출을 `transition(from, event)`로 교체 -- `handleSaveServiceAccountJson` 분리: 핸들러는 1번만 전이, API 호출은 useEffect로 분리 -- 34개 플로우 경로 테스트 (100% 커버리지) - -### 6. FirebaseWizard 새 기능 -- **파일**: `FirebaseWizard.tsx` -- Sender config 확인 3개 phase 추가 (`checking_sender_config`, `sender_config_registered`, `registering_sender_config`) -- `PasteServiceAccountPhase` 개선: 클립보드/JSON 직접입력/파일 드래그 자동 감지 -- Service Account 메뉴 단순화: "Create new" 제거, Console 다운로드 + Paste만 제공 - -### 7. Chat UI 통합 -- **파일**: `ChatApp.tsx`, `useOverlays.ts`, `useCommandHandler.ts`, `useMessageSending.ts` -- `install-preparation` 오버레이 타입 추가 -- `/install` 커맨드 실행 시 preparation UI 먼저 표시 -- `clixProjectId`를 `FirebaseWizard`에 전달 - -### 8. InstallPreparationUI 개선 -- **파일**: `InstallPreparationUI.tsx` -- 초기 진입 phase 판별 로직 추가 -- Ready phase에서 auto-continue 대신 명시적 액션 메뉴 제공 - -### 9. ProjectSelector 검색 기능 -- **파일**: `ProjectSelector.tsx` -- 프로젝트 검색/필터 기능 추가 -- 알파벳 정렬 (`Intl.Collator`) - -### 10. Organization/Projects 서비스 추출 -- **파일**: `organization-projects.ts` (신규), `organization-projects.test.ts` (신규) -- 동시 프로젝트 조회 (concurrency: 4) -- 지수 백오프 재시도 -- LoginUI, SetupUI에서 중복 로직 제거 - -### 11. 인증 관련 -- **파일**: `credentials.ts`, `LoginUI.tsx`, `SetupUI.tsx` -- `clearFirebaseTokens()` 견고성 개선 -- 조직/프로젝트 조회를 새 서비스로 이전 - -### 12. 문서화 -- **파일**: `AGENTS.md` -- FirebaseWizard 상태 머신 섹션 추가 (수정 규칙 4가지, 플로우 개요, 테스트 정보) - ---- - -## 검증 상태 - -| 검증 항목 | 상태 | -|-----------|------| -| `bun run check` (lint + typecheck) | ✅ 통과 | -| `bun test` (623 unit tests) | ✅ 통과 | -| `bun run build` | ✅ 성공 | -| `bun test tests/e2e/` (20 E2E tests) | ✅ 통과 | -| firebase-wizard-transitions 커버리지 | ✅ 100% | - ---- - -## 변경 파일 요약 (32개 파일, ~2,200줄) - -| 카테고리 | 파일 수 | 주요 변경량 | -|----------|---------|-----------| -| Firebase API 마이그레이션 | 5 | IAM 삭제, googleapis 적용 | -| OAuth/디버깅 | 4 | 스코프 단순화, debug.log | -| Internal API (sender config) | 3 | 재시도 로직, 새 API 메서드 | -| FirebaseWizard 리팩터링 | 3 | 전이 맵, 34 테스트 | -| Install preparation | 3 | 파일 탐지 개선 | -| Chat UI 통합 | 6 | preparation 오버레이 | -| ProjectSelector | 1 | 검색/필터 | -| Organization 서비스 | 2 | 동시 조회 추출 | -| 인증/기타 | 3 | credentials 개선 | -| 문서/의존성 | 3 | AGENTS.md, package.json | - ---- - -## 남은 작업 - -### 필수 -1. **E2E 수동 테스트** — 실제 Firebase 프로젝트로 전체 플로우 테스트 (OAuth → 다운로드 → Sender Config → SA 등록) -2. **PR 생성** — main 브랜치로 PR 생성 및 리뷰 요청 - -### 식별된 리스크 -- Sender Config 권한 불일치 → 명시적 에러 매핑으로 완화 -- FirebaseWizard phase 회귀 → transition-map 강제 + path 테스트로 완화 -- 동시 fetch 부분 실패 → UI 폴백으로 완화 -- OAuth 환경 드리프트 → debug 로그로 완화 diff --git a/scripts/embed-skills.ts b/scripts/embed-skills.ts index f4e7ad6..5107440 100644 --- a/scripts/embed-skills.ts +++ b/scripts/embed-skills.ts @@ -48,19 +48,6 @@ function generateEmbeddedSkillsFile(skills: Record): string { * Only local command prompts are embedded. */ -/** - * Skill metadata interface kept for backward compatibility. - */ -export interface SkillMetadata { - folder: string; - name: string; - commandName: string; - displayName: string; - shortDescription: string; - description: string; - userInvocable: boolean; -} - /** * Embedded prompt content by folder name. */ @@ -68,11 +55,6 @@ export const EMBEDDED_SKILLS: Record = { ${skillEntries} }; -/** - * Package-based metadata is intentionally empty. - */ -export const EMBEDDED_SKILL_METADATA: SkillMetadata[] = []; - export function hasEmbeddedSkills(): boolean { return Object.keys(EMBEDDED_SKILLS).length > 0; } @@ -84,18 +66,6 @@ export function getEmbeddedSkill(skillFolder: string): string | undefined { export function getEmbeddedSkillFolders(): string[] { return Object.keys(EMBEDDED_SKILLS); } - -export function getEmbeddedSkillMetadata(): SkillMetadata[] { - return EMBEDDED_SKILL_METADATA; -} - -export function getSkillMetadataByCommand(_commandName: string): SkillMetadata | undefined { - return undefined; -} - -export function getSkillMetadataByFolder(_folder: string): SkillMetadata | undefined { - return undefined; -} `; } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 5513d30..83bfae1 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,3 +1,4 @@ +import { AgentError } from '../lib/errors'; import { runCommandHandoff } from '../lib/services/agent-handoff'; const ADD_MCP_PACKAGE = 'add-mcp'; @@ -9,10 +10,6 @@ interface MCPCommandDependencies { exitProcess?: (code: number) => void; } -function buildMcpHandoffArgs(): string[] { - return [ADD_MCP_PACKAGE, CLIX_MCP_SERVER_PACKAGE, '--name', MCP_SERVER_NAME]; -} - export async function mcpCommand(dependencies: MCPCommandDependencies = {}): Promise { const runHandoff = dependencies.runHandoff ?? runCommandHandoff; const exitProcess = dependencies.exitProcess ?? ((code: number) => process.exit(code)); @@ -23,13 +20,13 @@ export async function mcpCommand(dependencies: MCPCommandDependencies = {}): Pro try { exitCode = await runHandoff({ command: 'npx', - args: buildMcpHandoffArgs(), + args: [ADD_MCP_PACKAGE, CLIX_MCP_SERVER_PACKAGE, '--name', MCP_SERVER_NAME], workingDirectory: process.cwd(), displayName: 'add-mcp', }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to launch add-mcp: ${message}`); + throw new AgentError(`Failed to launch add-mcp: ${message}`, 'add-mcp'); } exitProcess(exitCode); diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 2c19dbf..e653a30 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -1,3 +1,4 @@ +import { AgentError } from '../lib/errors'; import { runCommandHandoff } from '../lib/services/agent-handoff'; const DEFAULT_SKILLS_REPOSITORY = 'clix-so/skills'; @@ -23,7 +24,7 @@ export async function skillsCommand(dependencies: SkillsCommandDependencies = {} }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to launch Skills CLI: ${message}`); + throw new AgentError(`Failed to launch Skills CLI: ${message}`, 'Skills CLI'); } exitProcess(exitCode); diff --git a/src/lib/__tests__/embedded-skills.test.ts b/src/lib/__tests__/embedded-skills.test.ts index 686203b..b0f4e99 100644 --- a/src/lib/__tests__/embedded-skills.test.ts +++ b/src/lib/__tests__/embedded-skills.test.ts @@ -1,11 +1,8 @@ import { describe, expect, test } from 'bun:test'; import { - EMBEDDED_SKILL_METADATA, EMBEDDED_SKILLS, getEmbeddedSkill, getEmbeddedSkillFolders, - getSkillMetadataByCommand, - getSkillMetadataByFolder, hasEmbeddedSkills, } from '../embedded-skills'; @@ -27,10 +24,4 @@ describe('embedded-skills (local prompts only)', () => { expect(folders).toContain('local-install'); expect(folders).toContain('local-doctor'); }); - - test('package metadata list stays empty', () => { - expect(EMBEDDED_SKILL_METADATA).toEqual([]); - expect(getSkillMetadataByCommand('integration')).toBeUndefined(); - expect(getSkillMetadataByFolder('integration')).toBeUndefined(); - }); }); diff --git a/src/lib/__tests__/test-utils/fixtures.ts b/src/lib/__tests__/test-utils/fixtures.ts index 7828205..22527e1 100644 --- a/src/lib/__tests__/test-utils/fixtures.ts +++ b/src/lib/__tests__/test-utils/fixtures.ts @@ -1,58 +1,7 @@ -interface ConversationMessage { - role: 'user' | 'assistant'; - content: string; -} - /** * Test fixtures for various test scenarios */ export const FIXTURES = { - /** - * A short conversation history (2 messages) - */ - shortHistory: [ - { role: 'user' as const, content: 'Hello' }, - { role: 'assistant' as const, content: 'Hi there! How can I help you today?' }, - ] as ConversationMessage[], - - /** - * A medium conversation history (6 messages) - */ - mediumHistory: [ - { role: 'user' as const, content: 'What is TypeScript?' }, - { - role: 'assistant' as const, - content: 'TypeScript is a strongly typed programming language that builds on JavaScript.', - }, - { role: 'user' as const, content: 'How do I use interfaces?' }, - { - role: 'assistant' as const, - content: - 'Interfaces in TypeScript define the structure of objects. You use the interface keyword.', - }, - { role: 'user' as const, content: 'Can you show me an example?' }, - { - role: 'assistant' as const, - content: 'Sure! Here is an example: interface User { name: string; age: number; }', - }, - ] as ConversationMessage[], - - /** - * A long conversation history (20 messages, ~10KB total) - */ - longHistory: Array.from({ length: 20 }, (_, i) => ({ - role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant', - content: `Message ${i + 1}: `.padEnd(500, `This is content for message ${i + 1}. `), - })) as ConversationMessage[], - - /** - * A very long conversation that should trigger compaction (~200KB) - */ - compactionTriggerHistory: Array.from({ length: 100 }, (_, i) => ({ - role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant', - content: `Message ${i + 1}: `.padEnd(2000, `This is extended content for message ${i + 1}. `), - })) as ConversationMessage[], - /** * Mock configuration object */ @@ -106,10 +55,3 @@ export const FIXTURES = { */ platforms: ['ios', 'android', 'react-native', 'flutter'] as const, }; - -/** - * Calculate total character count of a history array - */ -export function calculateHistorySize(history: ConversationMessage[]): number { - return history.reduce((sum, msg) => sum + msg.content.length, 0); -} diff --git a/src/lib/constants/system-prompt.ts b/src/lib/constants/system-prompt.ts index 3531bf2..9fd3554 100644 --- a/src/lib/constants/system-prompt.ts +++ b/src/lib/constants/system-prompt.ts @@ -66,13 +66,7 @@ Platform-specific configs to verify: ## Available Slash Commands - /install - Install workflow: required setup, project build, and SDK integration -- /integration - SDK integration guide with step-by-step instructions -- /event-tracking - Event tracking setup -- /user-management - User management -- /personalization - Personalization templates -- /api-triggered-campaigns - API-triggered campaign setup - /doctor - Check SDK integration status -- /debug - Debug and investigate user-reported problems - /mcp - Install Clix MCP Server for AI agents ## Response Style diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index 0abe9af..a2314b1 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -5,19 +5,6 @@ * Only local command prompts are embedded. */ -/** - * Skill metadata interface kept for backward compatibility. - */ -export interface SkillMetadata { - folder: string; - name: string; - commandName: string; - displayName: string; - shortDescription: string; - description: string; - userInvocable: boolean; -} - /** * Embedded prompt content by folder name. */ @@ -215,11 +202,6 @@ Use 'clix install' to set up SDK integration if not yet installed. `, }; -/** - * Package-based metadata is intentionally empty. - */ -export const EMBEDDED_SKILL_METADATA: SkillMetadata[] = []; - export function hasEmbeddedSkills(): boolean { return Object.keys(EMBEDDED_SKILLS).length > 0; } @@ -231,15 +213,3 @@ export function getEmbeddedSkill(skillFolder: string): string | undefined { export function getEmbeddedSkillFolders(): string[] { return Object.keys(EMBEDDED_SKILLS); } - -export function getEmbeddedSkillMetadata(): SkillMetadata[] { - return EMBEDDED_SKILL_METADATA; -} - -export function getSkillMetadataByCommand(_commandName: string): SkillMetadata | undefined { - return undefined; -} - -export function getSkillMetadataByFolder(_folder: string): SkillMetadata | undefined { - return undefined; -} diff --git a/src/lib/executor.ts b/src/lib/executor.ts index 2776325..8c60a2a 100644 --- a/src/lib/executor.ts +++ b/src/lib/executor.ts @@ -2,7 +2,6 @@ import type { AgentInfo } from './agents'; export interface ExecuteOptions { workingDirectory?: string; - allowedTools?: string[]; signal?: AbortSignal; } diff --git a/src/lib/executors/__tests__/gemini-executor.test.ts b/src/lib/executors/__tests__/gemini-executor.test.ts index 7f52acc..71a41f5 100644 --- a/src/lib/executors/__tests__/gemini-executor.test.ts +++ b/src/lib/executors/__tests__/gemini-executor.test.ts @@ -44,19 +44,6 @@ describe('GeminiExecutor message mapping', () => { * These fixtures represent actual Gemini CLI output format (stream-json) */ describe('Gemini CLI message format fixtures', () => { - test('init event should have session_id and model', () => { - const msg: GeminiCLIMessage = { - type: 'init', - timestamp: '2025-01-14T00:00:00.000Z', - session_id: 'gemini-session-123', - model: 'gemini-2.5-pro', - }; - - expect(msg.type).toBe('init'); - expect(msg.session_id).toBe('gemini-session-123'); - expect(msg.model).toBe('gemini-2.5-pro'); - }); - test('message event with assistant role should have correct structure', () => { const msg: GeminiCLIMessage = { type: 'message', diff --git a/src/lib/executors/__tests__/opencode-executor.test.ts b/src/lib/executors/__tests__/opencode-executor.test.ts index d00ec2f..18b9ecf 100644 --- a/src/lib/executors/__tests__/opencode-executor.test.ts +++ b/src/lib/executors/__tests__/opencode-executor.test.ts @@ -76,18 +76,6 @@ describe('OpenCodeExecutor', () => { }); describe('OpenCode CLI message format', () => { - test('session event structure', () => { - const msg: OpenCodeCLIMessage = { - type: 'session', - timestamp: 1768445719603, - sessionID: 'ses_4406be0e0ffek7fDmqJpninhMz', - model: 'gpt-4', - }; - - expect(msg.type).toBe('session'); - expect(msg.sessionID).toBe('ses_4406be0e0ffek7fDmqJpninhMz'); - }); - test('error event structure (confirmed)', () => { const msg: OpenCodeCLIMessage = { type: 'error', diff --git a/src/lib/executors/base-executor.ts b/src/lib/executors/base-executor.ts index 5dbad71..2df4baf 100644 --- a/src/lib/executors/base-executor.ts +++ b/src/lib/executors/base-executor.ts @@ -14,6 +14,7 @@ import { spawnCLIProcess, waitForProcessExit, } from './cli-process-manager'; +import { extractCumulativeDelta } from './stream-delta'; /** * Stream parser type - determines how to parse CLI output @@ -54,6 +55,7 @@ export interface StreamContext { export abstract class BaseExecutor implements AgentExecutor { readonly name: string; protected readonly log: Logger; + protected lastTextContent = ''; private readonly command: string; private readonly notFoundMessage: string; @@ -123,6 +125,20 @@ export abstract class BaseExecutor implements AgentExecutor { return `${CLIX_SYSTEM_PROMPT}\n\n${prompt}`; } + protected extractTextDelta(textContent: string, metadata: unknown): AgentMessage | null { + const delta = extractCumulativeDelta(this.lastTextContent, textContent); + this.lastTextContent = textContent; + if (!delta) { + return null; + } + return { + type: 'text', + content: delta, + streamMode: 'append', + metadata: metadata as Record, + }; + } + async isAvailable(): Promise { return commandExists(this.command); } diff --git a/src/lib/executors/claude-executor.ts b/src/lib/executors/claude-executor.ts index 99fe1bf..bcca8e7 100644 --- a/src/lib/executors/claude-executor.ts +++ b/src/lib/executors/claude-executor.ts @@ -4,13 +4,9 @@ */ import type { AgentMessage, ExecuteOptions } from '../executor'; import { BaseExecutor, type StreamContext, type StreamParserType } from './base-executor'; -import { extractCumulativeDelta } from './stream-delta'; import type { CLIContentBlock, ClaudeCLIMessage } from './types'; export class ClaudeExecutor extends BaseExecutor { - // Track accumulated text to compute deltas - private lastTextContent = ''; - constructor() { super({ name: 'claude', @@ -92,23 +88,6 @@ export class ClaudeExecutor extends BaseExecutor { return results.length > 0 ? (results.length === 1 ? results[0] : results) : null; } - private extractTextDelta(textContent: string, msg: ClaudeCLIMessage): AgentMessage | null { - const delta = extractCumulativeDelta(this.lastTextContent, textContent); - - this.lastTextContent = textContent; - - if (delta) { - return { - type: 'text', - content: delta, - streamMode: 'append', - metadata: msg as unknown as Record, - }; - } - - return null; - } - private extractToolUses(msg: ClaudeCLIMessage): AgentMessage[] { const toolUses = msg.message?.content?.filter( @@ -158,6 +137,7 @@ export class ClaudeExecutor extends BaseExecutor { return { type: 'text', content: msg.result, + streamMode: 'append', metadata: msg as unknown as Record, }; } diff --git a/src/lib/executors/cursor-executor.ts b/src/lib/executors/cursor-executor.ts index 2754509..a074413 100644 --- a/src/lib/executors/cursor-executor.ts +++ b/src/lib/executors/cursor-executor.ts @@ -4,12 +4,9 @@ */ import type { AgentMessage, ExecuteOptions } from '../executor'; import { BaseExecutor, type StreamContext, type StreamParserType } from './base-executor'; -import { extractCumulativeDelta } from './stream-delta'; import type { CLIContentBlock, CursorCLIMessage } from './types'; export class CursorExecutor extends BaseExecutor { - private lastTextContent = ''; - constructor() { super({ name: 'cursor', @@ -48,23 +45,6 @@ export class CursorExecutor extends BaseExecutor { return args; } - private extractTextDelta(textContent: string, msg: CursorCLIMessage): AgentMessage | null { - const delta = extractCumulativeDelta(this.lastTextContent, textContent); - - this.lastTextContent = textContent; - - if (!delta) { - return null; - } - - return { - type: 'text', - content: delta, - streamMode: 'append', - metadata: msg as unknown as Record, - }; - } - private mapAssistantMessage(msg: CursorCLIMessage): AgentMessage | AgentMessage[] | null { if (!msg.message?.content) return null; @@ -138,6 +118,7 @@ export class CursorExecutor extends BaseExecutor { return { type: 'text', content: msg.result, + streamMode: 'append', metadata: msg as unknown as Record, }; } diff --git a/src/lib/executors/gemini-executor.ts b/src/lib/executors/gemini-executor.ts index 30553d8..d31cce7 100644 --- a/src/lib/executors/gemini-executor.ts +++ b/src/lib/executors/gemini-executor.ts @@ -15,8 +15,6 @@ import type { } from './types'; export class GeminiExecutor extends BaseExecutor { - private lastTextContent = ''; - constructor() { super({ name: 'gemini', diff --git a/src/lib/executors/opencode-executor.ts b/src/lib/executors/opencode-executor.ts index d21d358..e911a32 100644 --- a/src/lib/executors/opencode-executor.ts +++ b/src/lib/executors/opencode-executor.ts @@ -13,6 +13,17 @@ import type { OpenCodeCLIToolResultEvent, } from './types'; +export function createDefaultOpenCodeEnv(): NodeJS.ProcessEnv | undefined { + if (process.env.OPENCODE_PERMISSION) { + return undefined; + } + + return { + ...process.env, + OPENCODE_PERMISSION: '{"*":"allow"}', + }; +} + export class OpenCodeExecutor extends BaseExecutor { constructor() { super({ @@ -28,15 +39,7 @@ export class OpenCodeExecutor extends BaseExecutor { } protected override getSpawnEnv(_options?: ExecuteOptions): NodeJS.ProcessEnv | undefined { - if (process.env.OPENCODE_PERMISSION) { - return undefined; - } - - // Default to allow-all tool permission to avoid interactive approval prompts. - return { - ...process.env, - OPENCODE_PERMISSION: '{"*":"allow"}', - }; + return createDefaultOpenCodeEnv(); } protected buildArgs(prompt: string, _options?: ExecuteOptions): string[] { diff --git a/src/lib/executors/types.ts b/src/lib/executors/types.ts index 4c19615..048e047 100644 --- a/src/lib/executors/types.ts +++ b/src/lib/executors/types.ts @@ -69,13 +69,6 @@ export type GeminiCLIEventType = | 'error' | 'result'; -export interface GeminiCLIInitEvent { - type: 'init'; - timestamp: string; - session_id: string; - model: string; -} - export interface GeminiCLIMessageEvent { type: 'message'; timestamp: string; @@ -131,7 +124,6 @@ export interface GeminiCLIResultEvent { } export type GeminiCLIMessage = - | GeminiCLIInitEvent | GeminiCLIMessageEvent | GeminiCLIToolUseEvent | GeminiCLIToolResultEvent @@ -162,12 +154,6 @@ export interface OpenCodeCLIBaseEvent { sessionID?: string; } -export interface OpenCodeCLISessionEvent extends OpenCodeCLIBaseEvent { - type: 'session' | 'start'; - sessionID: string; - model?: string; -} - export interface OpenCodeCLIMessageEvent extends OpenCodeCLIBaseEvent { type: 'message'; content: string; @@ -257,7 +243,6 @@ export interface OpenCodeCLICompleteEvent extends OpenCodeCLIBaseEvent { } export type OpenCodeCLIMessage = - | OpenCodeCLISessionEvent | OpenCodeCLIStepStartEvent | OpenCodeCLIMessageEvent | OpenCodeCLITextEvent diff --git a/src/lib/ios/pbxproj-modifier.ts b/src/lib/ios/pbxproj-modifier.ts index fc48b5d..00a0c8f 100644 --- a/src/lib/ios/pbxproj-modifier.ts +++ b/src/lib/ios/pbxproj-modifier.ts @@ -158,6 +158,7 @@ function createNotificationServiceTargetWithStrictSafeApis( ): { target: { uuid: string }; warnings: string[]; + dependencyAdded?: boolean; } { if ( !project.generateUuid || @@ -295,9 +296,11 @@ function createNotificationServiceTargetWithStrictSafeApis( warnings.push('Created NSE target with fallback path; verify extension embedding in Xcode'); } + let dependencyAdded = false; if (firstTargetUuid && project.addTargetDependency) { try { project.addTargetDependency(firstTargetUuid, [targetId]); + dependencyAdded = true; } catch { warnings.push( 'Created NSE target but could not auto-add target dependency; verify target dependencies in Xcode', @@ -305,7 +308,7 @@ function createNotificationServiceTargetWithStrictSafeApis( } } - return { target: { uuid: targetId }, warnings }; + return { target: { uuid: targetId }, warnings, dependencyAdded }; } function createNotificationServiceTarget( @@ -321,6 +324,7 @@ function createNotificationServiceTarget( ): { target: { uuid: string } | null; warnings: string[]; + dependencyAdded?: boolean; } { try { return { @@ -350,6 +354,7 @@ function createNotificationServiceTarget( 'Applied strict-mode fallback while creating NSE target', ...fallbackResult.warnings, ], + dependencyAdded: fallbackResult.dependencyAdded, }; } } @@ -970,6 +975,25 @@ export async function ensureMainTargetEntitlementsLink(options: { } } +function addTargetDependencyToMainApp( + project: { addTargetDependency: (targetUuid: string, dependencyUuids: string[]) => void }, + targetUuid: string, + warnings: string[], +): void { + const appTargetUuid = findAppTargetUuid( + project as unknown as { pbxNativeTargetSection: () => Record | null }, + ); + if (appTargetUuid && targetUuid) { + try { + project.addTargetDependency(appTargetUuid, [targetUuid]); + } catch { + warnings.push('Could not add target dependency, extension may need manual embedding'); + } + } else { + warnings.push('Could not find main app target, extension may need manual embedding'); + } +} + /** * Add Notification Service Extension target to Xcode project. */ @@ -1000,8 +1024,9 @@ export async function addNotificationServiceExtension( // 2. Ensure target exists (create only if missing) let targetUuid = findTargetUuidByName(project, options.extensionName); + let targetDependencyAlreadyAdded = false; if (!targetUuid) { - const { target, warnings } = createNotificationServiceTarget( + const { target, warnings, dependencyAdded } = createNotificationServiceTarget( project as unknown as XcodeProjectWithStrictSafeTargetApis & { addTarget: ( name: string, @@ -1016,6 +1041,7 @@ export async function addNotificationServiceExtension( }, ); result.warnings.push(...warnings); + targetDependencyAlreadyAdded = dependencyAdded === true; if (!target) { result.error = 'Failed to add target to project'; @@ -1072,20 +1098,9 @@ export async function addNotificationServiceExtension( result.warnings.push(...buildSettingsResult.warnings); // 6. Add target dependency to main app (embed extension) - // Find the main app target by productType instead of using getFirstTarget() - if (result.targetAdded) { - const appTargetUuid = findAppTargetUuid(project); - if (appTargetUuid && targetUuid) { - try { - project.addTargetDependency(appTargetUuid, [targetUuid]); - } catch { - result.warnings.push( - 'Could not add target dependency, extension may need manual embedding', - ); - } - } else { - result.warnings.push('Could not find main app target, extension may need manual embedding'); - } + // Skip if dependency was already added during target creation (strict-safe fallback path) + if (result.targetAdded && !targetDependencyAlreadyAdded) { + addTargetDependencyToMainApp(project, targetUuid, result.warnings); } // 7. Write changes to project file diff --git a/src/lib/services/agent-handoff.ts b/src/lib/services/agent-handoff.ts index 2fe47b3..d411dd5 100644 --- a/src/lib/services/agent-handoff.ts +++ b/src/lib/services/agent-handoff.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { constants as osConstants } from 'node:os'; import type { AgentInfo } from '@/lib/agents'; +import { createDefaultOpenCodeEnv } from '@/lib/executors/opencode-executor'; type ProcessWithOptionalExecve = NodeJS.Process & { execve?: (file: string, args: string[], env?: NodeJS.ProcessEnv) => never; @@ -55,17 +56,6 @@ interface HandoffExecutionInvocation { displayName: string; } -function createDefaultOpenCodeEnv(): NodeJS.ProcessEnv | undefined { - if (process.env.OPENCODE_PERMISSION) { - return undefined; - } - - return { - ...process.env, - OPENCODE_PERMISSION: '{"*":"allow"}', - }; -} - export function buildAgentHandoffInvocation({ agent, prompt, diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index 46363e5..7283ddc 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -224,6 +224,9 @@ export class FirebaseApiClient { if (res.data.error) { throw new Error(`Operation failed: ${res.data.error.message}`); } + if (!res.data.response) { + throw new Error('Operation completed but returned no response'); + } return res.data.response as T; } await new Promise((resolve) => setTimeout(resolve, 1000)); diff --git a/src/lib/services/firebase/oauth/auth-client.ts b/src/lib/services/firebase/oauth/auth-client.ts index 55bb1aa..6ba5a42 100644 --- a/src/lib/services/firebase/oauth/auth-client.ts +++ b/src/lib/services/firebase/oauth/auth-client.ts @@ -340,7 +340,10 @@ export class GoogleAuthClient { // If refresh failed (e.g., invalid_grant), tokens are already cleared // Re-throw with indication that re-authentication is needed const message = error instanceof Error ? error.message : 'Token refresh failed'; - throw new Error(`invalid_grant: ${message}`); + if (message.includes('invalid_grant')) { + throw new Error(`invalid_grant: ${message}`); + } + throw new Error(`token_refresh_failed: ${message}`); } } diff --git a/src/ui/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx index 863044e..d2efd68 100644 --- a/src/ui/IosSetupUI.tsx +++ b/src/ui/IosSetupUI.tsx @@ -24,6 +24,7 @@ import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/ import { detectProjectType } from '@/lib/services/project-detector'; import { Header } from '@/ui/components/Header'; import { StatusMessage } from '@/ui/components/StatusMessage'; +import { useCancelInput } from '@/ui/hooks'; type SetupPhase = | 'analyzing' @@ -104,10 +105,6 @@ function promptTerminalInput(message: string, defaultValue?: string): Promise { - return promptTerminalInput(message); -} - function promptTerminalConfirm(message: string): Promise { return promptTerminalInput(`${message} (y/N)`).then( (answer) => answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes', @@ -140,7 +137,7 @@ async function syncWithPortal( try { const userAuth = await loginWithUserCredentialsAsync( promptTerminalInput, - promptTerminalPassword, + promptTerminalInput, promptTerminalConfirm, ); const context = getRequestContext(userAuth); @@ -345,6 +342,9 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = errorMessage, } = state; + // Allow ESC/Ctrl+C to cancel during authenticating phase + useCancelInput(() => exit(), { isActive: phase === 'authenticating' }); + // Handle user input for complete/error phases const handleContinue = useCallback(() => { if (phase === 'complete' && result) { diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx index 74bb165..deadb68 100644 --- a/src/ui/components/FirebaseConfigFilesSetup.tsx +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -4,19 +4,23 @@ import Spinner from 'ink-spinner'; import TextInput from 'ink-text-input'; import type React from 'react'; import { useCallback, useState } from 'react'; -import { - type AndroidApp, - type FirebaseDetectionResult, - type FirebaseProject, - type IosApp, - platformNeedsAndroid, - platformNeedsIos, +import type { + AndroidApp, + FirebaseDetectionResult, + FirebaseProject, + IosApp, } from '@/lib/services/firebase'; import { OAUTH_CALLBACK_CONFIG } from '@/lib/utils/oauth'; import { useCancelInput } from '@/ui/hooks'; import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; +export { + hasValidFirebaseConfigFiles, + platformNeedsAndroidWithUnknown, + platformNeedsIosWithUnknown, +} from './firebase-detection-utils'; + export interface NoAppsContext { noAndroidApps: boolean; noIosApps: boolean; @@ -24,26 +28,6 @@ export interface NoAppsContext { needsIos: boolean; } -export function platformNeedsAndroidWithUnknown( - platform: FirebaseDetectionResult['platform'], -): boolean { - return platformNeedsAndroid(platform) || platform === 'unknown'; -} - -export function platformNeedsIosWithUnknown( - platform: FirebaseDetectionResult['platform'], -): boolean { - return platformNeedsIos(platform) || platform === 'unknown'; -} - -export function hasValidFirebaseConfigFiles(result: FirebaseDetectionResult): boolean { - const needsAndroid = platformNeedsAndroidWithUnknown(result.platform); - const needsIos = platformNeedsIosWithUnknown(result.platform); - const hasAndroidConfig = !needsAndroid || Boolean(result.android?.valid); - const hasIosConfig = !needsIos || Boolean(result.ios?.valid); - return hasAndroidConfig && hasIosConfig; -} - function DetectingPhase(): React.ReactElement { return ( { const { execFile } = await import('node:child_process'); diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index 40ca2e9..4e2f68e 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -119,7 +119,7 @@ import { PushDetectionTask, } from './push-setup/PushSetupTasks'; -type PreparationPhase = 'checking' | 'config_missing' | 'status' | 'task' | 'cancelled'; +type PreparationPhase = 'checking' | 'config_missing' | 'status' | 'task'; type InstallLeafTaskId = | 'firebase_config_detecting' @@ -2561,14 +2561,6 @@ export function InstallPreparationUI({ return ; } - if (phase === 'cancelled') { - return ( - - Installation cancelled. - - ); - } - if (!context) { return ; } @@ -3129,6 +3121,7 @@ export function InstallPreparationUI({ return; } setInstallSkillError(null); + installSkillRunStartedRef.current = false; setActiveLeafTaskId('install_skill_running'); }} onCancel={() => diff --git a/src/ui/components/firebase-detection-utils.ts b/src/ui/components/firebase-detection-utils.ts new file mode 100644 index 0000000..866f155 --- /dev/null +++ b/src/ui/components/firebase-detection-utils.ts @@ -0,0 +1,25 @@ +import { + type FirebaseDetectionResult, + platformNeedsAndroid, + platformNeedsIos, +} from '@/lib/services/firebase'; + +export function platformNeedsAndroidWithUnknown( + platform: FirebaseDetectionResult['platform'], +): boolean { + return platformNeedsAndroid(platform) || platform === 'unknown'; +} + +export function platformNeedsIosWithUnknown( + platform: FirebaseDetectionResult['platform'], +): boolean { + return platformNeedsIos(platform) || platform === 'unknown'; +} + +export function hasValidFirebaseConfigFiles(result: FirebaseDetectionResult): boolean { + const needsAndroid = platformNeedsAndroidWithUnknown(result.platform); + const needsIos = platformNeedsIosWithUnknown(result.platform); + const hasAndroidConfig = !needsAndroid || Boolean(result.android?.valid); + const hasIosConfig = !needsIos || Boolean(result.ios?.valid); + return hasAndroidConfig && hasIosConfig; +} From 4ea4ce2aa6d4d26a5d8607b660cd6864592ad8e4 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 10:50:57 -0800 Subject: [PATCH 22/30] docs: update readme for command-mode architecture Restore supported agents table with free/paid tiers, add commands summary table, improve command descriptions, and add back development, links, and license sections removed during chat mode removal. --- README.md | 103 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 64 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 659dc65..58c09d7 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,10 @@ Command-mode AI assistant for Clix SDK installation and diagnostics. ## Features -- Command-only workflow (no interactive chat mode) -- Multiple AI agents: Claude, Codex, Gemini, OpenCode, Cursor, GitHub Copilot -- Step-based `install` preparation flow for Firebase/iOS push setup -- Interactive agent handoff for `install` and `doctor` -- Built-in MCP installer (`mcp`) +- Command-only workflow — no interactive chat mode +- Step-based `install` preparation flow for Firebase and iOS push setup +- Interactive agent handoff for `install` and `doctor` commands +- Built-in MCP server installer (`mcp`) - Built-in skills installer (`skills`) ## Installation @@ -38,68 +37,76 @@ brew tap clix-so/clix-cli brew install clix ``` +See [UNINSTALL.md](UNINSTALL.md) for removal instructions. + ## Prerequisites -- Node.js 20+ -- At least one supported AI agent installed on your machine +- **Node.js 20+** +- **One of the following AI agents**: + +| Agent | Free Plan | CLI Free Usage | +|-------|:---------:|----------------| +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Yes | 1,000 requests/day | +| [GitHub Copilot](https://docs.github.com/copilot/how-tos/set-up/install-copilot-cli) | Yes | 50 premium requests/month | +| [OpenCode](https://opencode.ai/docs/cli/) | Yes | Unlimited (with your own API keys) | +| [Cursor](https://cursor.com/cli) | Limited | 50 slow requests/month | +| [Claude Code](https://code.claude.com/docs) | No | Requires Pro ($20/mo) or API | +| [Codex](https://developers.openai.com/codex/cli) | No | Requires ChatGPT Plus ($20/mo) or API | + +> **Tip:** If you don't have an active subscription, start with **Gemini CLI** or **GitHub Copilot** which offer generous free tiers. ## Quick Start ```bash -clix --help -clix agent -clix install -clix doctor +clix agent # Select an AI agent +clix install # Install Clix SDK into your project +clix doctor # Diagnose SDK integration issues ``` ## Commands -### `clix` (no args) - -Shows help and exits. - -### `clix help` - -Shows help and exits. - -### `clix agent [name]` - -Lists available AI agents or switches the active agent. +| Command | Description | +|---------|-------------| +| `clix` / `clix help` | Show help and exit | +| `clix agent [name]` | List available agents or switch the active agent | +| `clix install` | Run install preparation, then hand off to agent CLI | +| `clix doctor` | Run SDK diagnostics via agent handoff | +| `clix mcp` | Install Clix MCP Server | +| `clix skills` | Install Clix skill package | +| `clix update` | Check for and apply CLI updates | +| `clix login` / `logout` / `whoami` | Account and authentication | +| `clix uninstall` | Remove Clix CLI from your system | ### `clix install [--start-task ]` -Runs the install pipeline: +Runs a two-phase install pipeline: -1. Preparation UI (step-by-step) -2. Interactive handoff to the selected agent CLI with the install prompt +1. **Preparation UI** — guided step-by-step setup for Firebase, APNS, and iOS configuration +2. **Agent handoff** — launches the selected AI agent CLI with the install prompt -`--start-task` is development-only and requires `CLIX_DEV_ENABLE_TASK_OVERRIDE=1`. +The `--start-task` flag is development-only and requires `CLIX_DEV_ENABLE_TASK_OVERRIDE=1`. ### `clix doctor` -Hands off to the selected agent CLI with the doctor prompt for SDK diagnostics. +Hands off to the selected AI agent CLI with a diagnostic prompt. The agent analyzes SDK integration status, dependency health, and configuration issues. ### `clix mcp` -Hands off to `npx add-mcp @clix-so/clix-mcp-server@latest --name clix` to install Clix MCP server. +Installs the Clix MCP Server via `npx add-mcp @clix-so/clix-mcp-server@latest --name clix`. ### `clix skills` -Hands off to `npx skills add clix-so/skills` and transfers CLI control. +Installs the Clix skill package via `npx skills add clix-so/skills` and transfers CLI control. ### `clix update [--dry-run] [--force]` -Checks for updates and applies them (or previews with `--dry-run`). - -### `clix login` / `clix logout` / `clix whoami` - -Account/authentication commands. +Checks for CLI updates and applies them. Use `--dry-run` to preview without applying, `--force` to skip confirmation. ### `clix uninstall [--keep-config] [--keep-state] [--dry-run] [--force]` -Uninstalls Clix CLI. +Removes Clix CLI. Use `--keep-config` or `--keep-state` to preserve local data. -## Install Preparation Order (iOS / push-related) +## Install Preparation Tasks `clix install` enforces the following order for required preparation tasks: @@ -109,8 +116,26 @@ Uninstalls Clix CLI. 4. iOS Entitlements 5. Notification Service Extension -Runtime installation executes only after required preparation steps are complete. +Runtime SDK installation executes only after all required preparation steps are complete. + +## Development + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development guide. + +```bash +bun install # Install dependencies +bun run dev # Run CLI from source +bun run build # Bundle for distribution +bun test # Run all tests +bun run check # Lint + typecheck +``` + +## Links + +- [Clix SDK Documentation](https://clix.so) +- [Issue Tracker](https://github.com/clix-so/clix-cli/issues) +- [LLMs.txt](llms.txt) — Documentation for AI assistants -## Notes +## License -- `project-build` is internal-only and not exposed as a user command. +MIT From e0b76047a477daada7ae09f2af3c41bb969bae03 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 11:05:27 -0800 Subject: [PATCH 23/30] fix: address PR review feedback across 10 files Security: - Harden service-account key storage with 0o600 file permissions - Redact sensitive OAuth params (code, state) before writing to debug.log Bug fixes: - Normalize Firebase project state instead of type-asserting - Clamp fractional concurrency to at least 1 - Merge provided env with process.env in CLI process spawn - Handle indented end lines in Podfile target extraction - Wrap execve in try/catch for spawn fallback Robustness: - Add safeStringify in logger to handle circular/unserializable data - Clean up redundant env var assignment in handoff test - Add supported agents list to llms.txt --- llms.txt | 2 ++ src/lib/debug/logger.ts | 14 ++++++-- src/lib/executors/cli-process-manager.ts | 2 +- src/lib/ios/podfile-modifier.ts | 2 +- .../services/__tests__/agent-handoff.test.ts | 1 - src/lib/services/agent-handoff.ts | 6 +++- src/lib/services/firebase/api/firebase-api.ts | 2 +- src/lib/services/firebase/downloader.ts | 7 ++-- src/lib/services/organization-projects.ts | 5 +-- src/lib/utils/oauth.ts | 36 +++++++++++-------- 10 files changed, 52 insertions(+), 25 deletions(-) diff --git a/llms.txt b/llms.txt index 88f589a..66d0275 100644 --- a/llms.txt +++ b/llms.txt @@ -6,6 +6,8 @@ Clix CLI runs in command mode only. It does not start an interactive chat UI. +Supported AI agents: Claude Code, Codex, Gemini CLI, OpenCode, Cursor, GitHub Copilot. + Primary commands: - `clix install` - `clix doctor` diff --git a/src/lib/debug/logger.ts b/src/lib/debug/logger.ts index 5920c4c..81e6bb9 100644 --- a/src/lib/debug/logger.ts +++ b/src/lib/debug/logger.ts @@ -199,6 +199,14 @@ export class Logger { * @param projectRoot - Project root directory (optional, uses cwd if not provided) */ writeToFile(message: string, data?: unknown, projectRoot?: string): void { + const safeStringify = (value: unknown): string => { + try { + return JSON.stringify(value); + } catch { + return '[unserializable]'; + } + }; + // Try multiple locations to ensure logging works const locations = [ projectRoot, @@ -214,7 +222,8 @@ export class Logger { mkdirSync(clixDir, { recursive: true }); const timestamp = new Date().toISOString(); - const line = `${timestamp} ${this.namespace} ${message}${data !== undefined ? ` ${JSON.stringify(data)}` : ''}\n`; + const serialized = data !== undefined ? safeStringify(data) : ''; + const line = `${timestamp} ${this.namespace} ${message}${data !== undefined ? ` ${serialized}` : ''}\n`; appendFileSync(logFile, line); return; // Success, exit loop } catch { @@ -222,7 +231,8 @@ export class Logger { } } // All locations failed, log to stderr as last resort - console.error(`[${this.namespace}] ${message}`, data !== undefined ? JSON.stringify(data) : ''); + const fallback = data !== undefined ? safeStringify(data) : ''; + console.error(`[${this.namespace}] ${message}`, fallback); } } diff --git a/src/lib/executors/cli-process-manager.ts b/src/lib/executors/cli-process-manager.ts index 2d7cb2c..634437a 100644 --- a/src/lib/executors/cli-process-manager.ts +++ b/src/lib/executors/cli-process-manager.ts @@ -84,7 +84,7 @@ export function spawnCLIProcess(options: CLIProcessOptions): CLIProcessResult { cwd: workingDirectory, stdio: ['pipe', 'pipe', 'pipe'], shell: false, - env, + env: env ? { ...process.env, ...env } : process.env, }); // Close stdin immediately - CLI tools like claude/codex need EOF on stdin to start processing diff --git a/src/lib/ios/podfile-modifier.ts b/src/lib/ios/podfile-modifier.ts index 5f4725c..afb0577 100644 --- a/src/lib/ios/podfile-modifier.ts +++ b/src/lib/ios/podfile-modifier.ts @@ -53,7 +53,7 @@ export function hasExtensionTarget(iosDir: string, extensionName: string): boole function extractTargetBlock(content: string, targetName: string): string | null { const targetRegex = new RegExp( - `target\\s+['"]${escapeRegex(targetName)}['"]\\s+do([\\s\\S]*?)\\nend`, + `target\\s+['"]${escapeRegex(targetName)}['"]\\s+do([\\s\\S]*?)\\n\\s*end`, 'i', ); const match = content.match(targetRegex); diff --git a/src/lib/services/__tests__/agent-handoff.test.ts b/src/lib/services/__tests__/agent-handoff.test.ts index ebddd30..431f309 100644 --- a/src/lib/services/__tests__/agent-handoff.test.ts +++ b/src/lib/services/__tests__/agent-handoff.test.ts @@ -87,7 +87,6 @@ describe('buildAgentHandoffInvocation', () => { test('injects OPENCODE_PERMISSION by default for opencode', () => { const previous = process.env.OPENCODE_PERMISSION; - process.env.OPENCODE_PERMISSION = ''; process.env.OPENCODE_PERMISSION = undefined; const invocation = buildAgentHandoffInvocation({ diff --git a/src/lib/services/agent-handoff.ts b/src/lib/services/agent-handoff.ts index d411dd5..1413bf6 100644 --- a/src/lib/services/agent-handoff.ts +++ b/src/lib/services/agent-handoff.ts @@ -181,7 +181,11 @@ async function runHandoffInvocation( ): Promise { const execve = getExecve(); if (execve) { - execve(invocation.command, [invocation.command, ...invocation.args], invocation.env); + try { + execve(invocation.command, [invocation.command, ...invocation.args], invocation.env); + } catch { + // execve failed (e.g., command not found), fall through to spawn + } } return await new Promise((resolve, reject) => { diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index 7283ddc..d5f871d 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -85,7 +85,7 @@ export class FirebaseApiClient { projectId: p.projectId ?? '', projectNumber: p.projectNumber ?? '', displayName: p.displayName ?? '', - state: (p.state as 'ACTIVE' | 'DELETED') ?? 'ACTIVE', + state: p.state === 'DELETED' ? 'DELETED' : 'ACTIVE', }); } pageToken = res.data.nextPageToken ?? undefined; diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index 76d5981..6bdfec6 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -335,8 +335,11 @@ export class FirebaseDownloader { const clixDir = path.join(projectPath, '.clix'); const savePath = path.join(clixDir, 'service-account.json'); - await fs.mkdir(clixDir, { recursive: true }); - await fs.writeFile(savePath, JSON.stringify(serviceAccountJson, null, 2), 'utf-8'); + await fs.mkdir(clixDir, { recursive: true, mode: 0o700 }); + await fs.writeFile(savePath, JSON.stringify(serviceAccountJson, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); return savePath; } diff --git a/src/lib/services/organization-projects.ts b/src/lib/services/organization-projects.ts index cac19a9..352c036 100644 --- a/src/lib/services/organization-projects.ts +++ b/src/lib/services/organization-projects.ts @@ -20,10 +20,11 @@ export interface FetchOrganizationsWithProjectsOptions { } function normalizeConcurrency(input?: number): number { - if (!input || !Number.isFinite(input) || input <= 0) { + const normalized = Math.floor(input ?? NaN); + if (!Number.isFinite(normalized) || normalized <= 0) { return DEFAULT_PROJECT_FETCH_CONCURRENCY; } - return Math.floor(input); + return normalized; } async function mapWithConcurrency( diff --git a/src/lib/utils/oauth.ts b/src/lib/utils/oauth.ts index 4103cb2..ba9e1e0 100644 --- a/src/lib/utils/oauth.ts +++ b/src/lib/utils/oauth.ts @@ -272,20 +272,7 @@ export class OAuthCallbackServer { // Handle OAuth error if (error) { const errorMsg = errorDescription ? `${error}: ${errorDescription}` : error; - - // Write debug info to .clix/debug.log - oauthLogger.writeToFile( - 'OAuth callback error', - { - type: 'oauth_callback_error', - error, - error_description: errorDescription, - full_url: req.url, - all_params: Object.fromEntries(url.searchParams.entries()), - }, - findProjectRoot(), - ); - + this.logOAuthCallbackError(error, errorDescription, req.url); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.options.errorHtml(errorMsg)); this.rejectPendingCallback(new Error(errorMsg)); @@ -384,4 +371,25 @@ export class OAuthCallbackServer { this.clearPendingCallback(); resolve(result); } + + private logOAuthCallbackError( + error: string, + errorDescription: string | null, + rawUrl: string | undefined, + ): void { + const sanitizedUrl = (rawUrl ?? '') + .replace(/code=[^&]+/g, 'code=[redacted]') + .replace(/state=[^&]+/g, 'state=[redacted]'); + + oauthLogger.writeToFile( + 'OAuth callback error', + { + type: 'oauth_callback_error', + error, + error_description: errorDescription, + full_url: sanitizedUrl, + }, + findProjectRoot(), + ); + } } From da7929d15b5385d9b66614410250533c5802dd16 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 11:11:19 -0800 Subject: [PATCH 24/30] fix: remove dead open package import causing CI type error The open dependency was intentionally removed but browser.ts still attempted to import it. Use platform-specific fallback directly. --- src/lib/auth/browser.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/lib/auth/browser.ts b/src/lib/auth/browser.ts index 053d75c..f4ee5d4 100644 --- a/src/lib/auth/browser.ts +++ b/src/lib/auth/browser.ts @@ -5,15 +5,7 @@ * @returns true if browser was opened, false otherwise */ export async function openBrowser(url: string): Promise { - try { - // Dynamic import to avoid bundling issues - const open = await import('open'); - await open.default(url); - return true; - } catch { - // Fallback: try platform-specific commands - return openBrowserFallback(url); - } + return openBrowserFallback(url); } /** From 38afc66d8d6ed9297c10f5c6f3c2547e6162427a Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 11:18:17 -0800 Subject: [PATCH 25/30] fix: address 5 remaining PR review findings - Set version to CURRENT_PROJECT_CONFIG_VERSION in ensureLatestVersion to prevent needless re-save on every startup - Rename "Slash Commands" to "Available Commands" in system prompt - Allow Apple ID override from cache instead of early return - Enforce NSE target dependency for existing targets missing it - Fix pod insertion regex to handle indented end blocks --- src/lib/config/project-config-schema.ts | 1 + src/lib/constants/system-prompt.ts | 8 ++++---- src/lib/ios/apple-auth.ts | 11 ++++++----- src/lib/ios/pbxproj-modifier.ts | 4 ++-- src/lib/ios/podfile-modifier.ts | 4 ++-- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/lib/config/project-config-schema.ts b/src/lib/config/project-config-schema.ts index 0a6dfff..60774a3 100644 --- a/src/lib/config/project-config-schema.ts +++ b/src/lib/config/project-config-schema.ts @@ -216,6 +216,7 @@ export function ensureLatestVersion(config: ProjectConfig): ProjectConfig { return { ...config, + version: CURRENT_PROJECT_CONFIG_VERSION, project: { id: config.project.id, name: config.project.name, diff --git a/src/lib/constants/system-prompt.ts b/src/lib/constants/system-prompt.ts index 9fd3554..737f322 100644 --- a/src/lib/constants/system-prompt.ts +++ b/src/lib/constants/system-prompt.ts @@ -64,10 +64,10 @@ Platform-specific configs to verify: - Remind user to replace with actual credentials - Verify placeholder replacement before completion -## Available Slash Commands -- /install - Install workflow: required setup, project build, and SDK integration -- /doctor - Check SDK integration status -- /mcp - Install Clix MCP Server for AI agents +## Available Commands +- install - Install workflow: required setup, project build, and SDK integration +- doctor - Check SDK integration status +- mcp - Install Clix MCP Server for AI agents ## Response Style - Clear, concise with bullet points diff --git a/src/lib/ios/apple-auth.ts b/src/lib/ios/apple-auth.ts index d1ec16e..27fee73 100644 --- a/src/lib/ios/apple-auth.ts +++ b/src/lib/ios/apple-auth.ts @@ -116,16 +116,17 @@ export async function promptAppleIdAsync( const lastAppleId = await getCachedUsernameAsync(); if (lastAppleId) { - console.log(`› Using cached Apple ID: ${lastAppleId}`); - return lastAppleId; + console.log(`› Cached Apple ID detected: ${lastAppleId}`); } - console.log('› Log in to your Apple Developer account to continue'); let username = ''; while (!username.trim()) { - username = await promptFn('Apple ID:', lastAppleId ?? undefined); - username = removeControlCharacters(username); + const input = await promptFn('Apple ID:', lastAppleId ?? undefined); + username = removeControlCharacters(input); + if (!username.trim() && lastAppleId) { + username = lastAppleId; + } if (!username.trim()) { console.log( '› Apple ID cannot be empty. Enter your Apple ID email or press Ctrl+C to cancel.', diff --git a/src/lib/ios/pbxproj-modifier.ts b/src/lib/ios/pbxproj-modifier.ts index 00a0c8f..ff5c54b 100644 --- a/src/lib/ios/pbxproj-modifier.ts +++ b/src/lib/ios/pbxproj-modifier.ts @@ -1098,8 +1098,8 @@ export async function addNotificationServiceExtension( result.warnings.push(...buildSettingsResult.warnings); // 6. Add target dependency to main app (embed extension) - // Skip if dependency was already added during target creation (strict-safe fallback path) - if (result.targetAdded && !targetDependencyAlreadyAdded) { + // Skip only if dependency was already added during target creation + if (!targetDependencyAlreadyAdded) { addTargetDependencyToMainApp(project, targetUuid, result.warnings); } diff --git a/src/lib/ios/podfile-modifier.ts b/src/lib/ios/podfile-modifier.ts index afb0577..523471b 100644 --- a/src/lib/ios/podfile-modifier.ts +++ b/src/lib/ios/podfile-modifier.ts @@ -136,8 +136,8 @@ export async function addClixToExtensionTarget( } const updatedBlock = targetBlock.replace( - /\nend$/i, - `\n pod '${options.clixPodSpec || 'Clix'}'\nend`, + /\n(\s*)end$/i, + `\n$1 pod '${options.clixPodSpec || 'Clix'}'\n$1end`, ); content = content.replace(targetBlock, updatedBlock); From 330e45f271f28b6598a73bb071a73e91728aea49 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 12:10:51 -0800 Subject: [PATCH 26/30] docs: Standardize "skill package" to "Skills" in documentation and CLI help, and improve README table formatting. --- README.md | 40 ++++++++++++++++++++-------------------- src/cli.tsx | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 58c09d7..2d9d063 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,14 @@ See [UNINSTALL.md](UNINSTALL.md) for removal instructions. - **Node.js 20+** - **One of the following AI agents**: -| Agent | Free Plan | CLI Free Usage | -|-------|:---------:|----------------| -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Yes | 1,000 requests/day | -| [GitHub Copilot](https://docs.github.com/copilot/how-tos/set-up/install-copilot-cli) | Yes | 50 premium requests/month | -| [OpenCode](https://opencode.ai/docs/cli/) | Yes | Unlimited (with your own API keys) | -| [Cursor](https://cursor.com/cli) | Limited | 50 slow requests/month | -| [Claude Code](https://code.claude.com/docs) | No | Requires Pro ($20/mo) or API | -| [Codex](https://developers.openai.com/codex/cli) | No | Requires ChatGPT Plus ($20/mo) or API | +| Agent | Free Plan | CLI Free Usage | +| ------------------------------------------------------------------------------------ | :-------: | ------------------------------------- | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Yes | 1,000 requests/day | +| [GitHub Copilot](https://docs.github.com/copilot/how-tos/set-up/install-copilot-cli) | Yes | 50 premium requests/month | +| [OpenCode](https://opencode.ai/docs/cli/) | Yes | Unlimited (with your own API keys) | +| [Cursor](https://cursor.com/cli) | Limited | 50 slow requests/month | +| [Claude Code](https://code.claude.com/docs) | No | Requires Pro ($20/mo) or API | +| [Codex](https://developers.openai.com/codex/cli) | No | Requires ChatGPT Plus ($20/mo) or API | > **Tip:** If you don't have an active subscription, start with **Gemini CLI** or **GitHub Copilot** which offer generous free tiers. @@ -65,17 +65,17 @@ clix doctor # Diagnose SDK integration issues ## Commands -| Command | Description | -|---------|-------------| -| `clix` / `clix help` | Show help and exit | -| `clix agent [name]` | List available agents or switch the active agent | -| `clix install` | Run install preparation, then hand off to agent CLI | -| `clix doctor` | Run SDK diagnostics via agent handoff | -| `clix mcp` | Install Clix MCP Server | -| `clix skills` | Install Clix skill package | -| `clix update` | Check for and apply CLI updates | -| `clix login` / `logout` / `whoami` | Account and authentication | -| `clix uninstall` | Remove Clix CLI from your system | +| Command | Description | +| ---------------------------------- | --------------------------------------------------- | +| `clix` / `clix help` | Show help and exit | +| `clix agent [name]` | List available agents or switch the active agent | +| `clix install` | Run install preparation, then hand off to agent CLI | +| `clix doctor` | Run SDK diagnostics via agent handoff | +| `clix mcp` | Install Clix MCP Server | +| `clix skills` | Install Clix Skills | +| `clix update` | Check for and apply CLI updates | +| `clix login` / `logout` / `whoami` | Account and authentication | +| `clix uninstall` | Remove Clix CLI from your system | ### `clix install [--start-task ]` @@ -96,7 +96,7 @@ Installs the Clix MCP Server via `npx add-mcp @clix-so/clix-mcp-server@latest -- ### `clix skills` -Installs the Clix skill package via `npx skills add clix-so/skills` and transfers CLI control. +Installs the Clix Skills via `npx skills add clix-so/skills` and transfers CLI control. ### `clix update [--dry-run] [--force]` diff --git a/src/cli.tsx b/src/cli.tsx index a666b57..53c6603 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -30,7 +30,7 @@ function generateHelpText(): string { install Install Clix SDK (step-by-step setup + interactive agent handoff) doctor Check Clix SDK integration status mcp Install Clix MCP Server - skills Install Clix skill package via skills CLI + skills Install Clix Skills uninstall Uninstall Clix CLI from your system update Check for available updates From d1a0da60d49967762817f258c1291466379646fe Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 12:14:01 -0800 Subject: [PATCH 27/30] chore: bump package version to 1.1.2-beta.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f1327eb..27a4985 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clix-so/clix-cli", - "version": "1.1.2-beta.2", + "version": "1.1.2-beta.3", "description": "A CLI tool for integrating and managing the Clix SDK in your mobile projects", "type": "module", "bin": { From 4609c44357c3c6ebfe5e7c83b76ea4329920b189 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 13:23:20 -0800 Subject: [PATCH 28/30] fix: ensure .gitignore is updated when .clix directory is first created Previously ensureGitignore was only called after project selection in SetupUI/LoginUI. If the user interrupted (Ctrl+C) after login but before project selection, the .clix directory was created without a matching .gitignore entry. Extracted gitignore logic to shared utility and moved the call into directory creation (ensureStateDir/ensureConfigDir) so it runs exactly once at the earliest possible moment. --- src/lib/auth/credentials.ts | 4 +- src/lib/config/project-config-manager.ts | 56 +---------------------- src/lib/utils/gitignore.ts | 57 ++++++++++++++++++++++++ src/ui/LoginUI.tsx | 3 -- src/ui/SetupUI.tsx | 3 -- 5 files changed, 62 insertions(+), 61 deletions(-) create mode 100644 src/lib/utils/gitignore.ts diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index c86bedb..22fca1a 100644 --- a/src/lib/auth/credentials.ts +++ b/src/lib/auth/credentials.ts @@ -1,5 +1,6 @@ import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { ensureClixGitignore } from '../utils/gitignore'; import { findProjectRoot } from '../utils/path'; import { AUTH_ENV_VARS, getAuth0Config } from './config'; import { AuthError } from './errors'; @@ -70,6 +71,7 @@ export class CredentialsManager { await stat(this.stateDirPath); } catch { await mkdir(this.stateDirPath, { recursive: true, mode: 0o755 }); + await ensureClixGitignore(dirname(this.stateDirPath)); } } diff --git a/src/lib/config/project-config-manager.ts b/src/lib/config/project-config-manager.ts index 4341aef..0ccf8b7 100644 --- a/src/lib/config/project-config-manager.ts +++ b/src/lib/config/project-config-manager.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { ConfigError, ERROR_CODES } from '../errors/types'; +import { ensureClixGitignore } from '../utils/gitignore'; import { CURRENT_PROJECT_CONFIG_VERSION, ensureLatestVersion, @@ -16,16 +17,6 @@ import { */ const CONFIG_HEADER = ''; -/** - * Gitignore patterns to check for .clix directory. - */ -const GITIGNORE_PATTERNS = ['.clix', '.clix/', '/.clix', '/.clix/']; - -/** - * Gitignore entry to add. - */ -const GITIGNORE_ENTRY = '\n# Clix CLI local config\n.clix/\n'; - /** * ProjectConfigManager handles loading and saving project-local configuration. * Configuration is stored in .clix/config.jsonc in the project root. @@ -94,6 +85,7 @@ export class ProjectConfigManager { await stat(this.configDirPath); } catch { await mkdir(this.configDirPath, { recursive: true, mode: 0o755 }); + await ensureClixGitignore(this.projectPath); } } @@ -221,50 +213,6 @@ export class ProjectConfigManager { } } - /** - * Ensure .clix is in .gitignore. - * Adds entry if not already present. - * - * @returns True if gitignore was modified - */ - async ensureGitignore(): Promise { - const gitignorePath = join(this.projectPath, '.gitignore'); - - try { - let content = ''; - - // Try to read existing .gitignore - try { - content = await readFile(gitignorePath, 'utf-8'); - } catch { - // File doesn't exist, will create new one - } - - // Check if .clix is already ignored - const lines = content.split('\n'); - const hasClixIgnore = lines.some((line) => { - const trimmed = line.trim(); - return GITIGNORE_PATTERNS.includes(trimmed); - }); - - if (hasClixIgnore) { - return false; - } - - // Add .clix to gitignore - const newContent = - content.endsWith('\n') || content === '' - ? `${content}${GITIGNORE_ENTRY}` - : `${content}\n${GITIGNORE_ENTRY}`; - - await writeFile(gitignorePath, newContent, 'utf-8'); - return true; - } catch { - // Non-fatal: log warning but continue - return false; - } - } - /** * Clear the cached config (useful for testing). */ diff --git a/src/lib/utils/gitignore.ts b/src/lib/utils/gitignore.ts new file mode 100644 index 0000000..6960cdd --- /dev/null +++ b/src/lib/utils/gitignore.ts @@ -0,0 +1,57 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * Gitignore patterns to check for .clix directory. + */ +const GITIGNORE_PATTERNS = ['.clix', '.clix/', '/.clix', '/.clix/']; + +/** + * Gitignore entry to add. + */ +const GITIGNORE_ENTRY = '\n# Clix CLI local config\n.clix/\n'; + +/** + * Ensure .clix is in .gitignore at the given project path. + * Adds entry if not already present. Creates .gitignore if it doesn't exist. + * + * @param projectPath - The project root directory containing .gitignore + * @returns True if gitignore was modified + */ +export async function ensureClixGitignore(projectPath: string): Promise { + const gitignorePath = join(projectPath, '.gitignore'); + + try { + let content = ''; + + // Try to read existing .gitignore + try { + content = await readFile(gitignorePath, 'utf-8'); + } catch { + // File doesn't exist, will create new one + } + + // Check if .clix is already ignored + const lines = content.split('\n'); + const hasClixIgnore = lines.some((line) => { + const trimmed = line.trim(); + return GITIGNORE_PATTERNS.includes(trimmed); + }); + + if (hasClixIgnore) { + return false; + } + + // Add .clix to gitignore + const newContent = + content.endsWith('\n') || content === '' + ? `${content}${GITIGNORE_ENTRY}` + : `${content}\n${GITIGNORE_ENTRY}`; + + await writeFile(gitignorePath, newContent, 'utf-8'); + return true; + } catch { + // Non-fatal: log warning but continue + return false; + } +} diff --git a/src/ui/LoginUI.tsx b/src/ui/LoginUI.tsx index 99f476f..cdec934 100644 --- a/src/ui/LoginUI.tsx +++ b/src/ui/LoginUI.tsx @@ -141,9 +141,6 @@ export const LoginUI: React.FC = ({ onComplete, onError }) => { const projectConfigManager = getProjectConfigManager(workspacePath); await projectConfigManager.save(projectConfig); - // Ensure .clix is in .gitignore - await projectConfigManager.ensureGitignore(); - setSavedConfig(projectConfig); setPhase('complete'); setTimeout(() => { diff --git a/src/ui/SetupUI.tsx b/src/ui/SetupUI.tsx index f024377..a67460f 100644 --- a/src/ui/SetupUI.tsx +++ b/src/ui/SetupUI.tsx @@ -129,9 +129,6 @@ export const SetupUI: React.FC = ({ onComplete, onError, projectPa const projectConfigManager = getProjectConfigManager(workspacePath); await projectConfigManager.save(config); - // Ensure .clix is in .gitignore - await projectConfigManager.ensureGitignore(); - setSavedConfig(config); setPhase('complete'); From 8a1c62ae4caa4834cf6ecc09f135f4ecf97b6350 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 20:21:42 -0800 Subject: [PATCH 29/30] refactor: centralize process exit, fix P8 drag-drop input, and add Firebase iOS app Team ID management - Introduce setExitCode() utility and centralize process.exit() to a single main().finally() guard to prevent terminal hang on command cancellation - Fix P8 file path input corruption when drag-dropping files from macOS Finder by replacing ink-text-input with ref-based accumulation (avoids React state batching race condition with multi-chunk stdin paste) - Add Firebase iOS app patchIosApp API for updating teamId/appStoreId - Detect Team ID mismatch between APNS key and Firebase iOS app after registration, with option to auto-update - Refactor IosSetupUI Apple login from terminal readline prompts to Ink-based TextInput UI with session restore support - Pass appName from Xcode project analysis to Firebase app creation --- src/cli.tsx | 12 +- src/commands/login.tsx | 3 +- src/commands/logout.tsx | 3 +- src/commands/mcp.ts | 3 +- src/commands/skill/index.tsx | 18 +- src/commands/skill/preparation.ts | 4 + src/commands/skills.ts | 3 +- src/commands/uninstall.ts | 6 +- src/commands/update.ts | 9 +- src/commands/whoami.tsx | 3 +- src/lib/exit.ts | 10 + src/lib/ios/extension-templates.ts | 1 - src/lib/services/firebase/api/firebase-api.ts | 31 ++ src/lib/services/firebase/api/index.ts | 1 + src/lib/services/firebase/api/types.ts | 10 + src/lib/services/firebase/downloader.ts | 21 + src/ui/IosSetupUI.tsx | 505 +++++++++++++----- .../components/FirebaseConfigFilesSetup.tsx | 126 ++++- src/ui/components/InstallPreparationUI.tsx | 100 +++- .../ios-setup/IosEntitlementsTask.tsx | 9 +- .../components/push-setup/PushSetupTasks.tsx | 76 ++- 21 files changed, 782 insertions(+), 172 deletions(-) create mode 100644 src/lib/exit.ts diff --git a/src/cli.tsx b/src/cli.tsx index 53c6603..67a69bd 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -10,6 +10,7 @@ import { skillsCommand } from './commands/skills'; import { uninstallCommand } from './commands/uninstall'; import { updateCommand } from './commands/update'; import { whoamiCommand } from './commands/whoami'; +import { setExitCode } from './lib/exit'; import { checkFirstRun, shouldRunSetup } from './lib/services/first-run-service'; /** @@ -127,7 +128,8 @@ async function main() { case 'mcp': if (cli.input[1]) { console.error('mcp command does not accept positional arguments.'); - process.exit(1); + setExitCode(1); + break; } await mcpCommand(); break; @@ -168,7 +170,7 @@ async function main() { // Unknown command - show error message console.error(`Unknown command: ${command}`); console.error(`Run 'clix help' to see available commands.`); - process.exit(1); + setExitCode(1); } else { // No command provided - show command help cli.showHelp(); @@ -177,8 +179,10 @@ async function main() { } } catch (error) { console.error('Error:', error instanceof Error ? error.message : 'Unknown error'); - process.exit(1); + setExitCode(1); } } -main(); +main().finally(() => { + process.exit(process.exitCode ?? 0); +}); diff --git a/src/commands/login.tsx b/src/commands/login.tsx index d3176a3..0e889a7 100644 --- a/src/commands/login.tsx +++ b/src/commands/login.tsx @@ -1,3 +1,4 @@ +import { setExitCode } from '../lib/exit'; import { LoginUI } from '../ui/LoginUI'; import { printFinalOutput } from '../ui/utils/finalOutput'; import { safeRender } from '../ui/utils/safeRender'; @@ -27,7 +28,7 @@ export async function loginCommand(): Promise { title: 'Login failed', message: error.message, }); - process.exitCode = 1; + setExitCode(1); resolve(); }} />, diff --git a/src/commands/logout.tsx b/src/commands/logout.tsx index 6ef30e0..e099342 100644 --- a/src/commands/logout.tsx +++ b/src/commands/logout.tsx @@ -1,3 +1,4 @@ +import { setExitCode } from '../lib/exit'; import { LogoutUI } from '../ui/LogoutUI'; import { printFinalOutput } from '../ui/utils/finalOutput'; import { safeRender } from '../ui/utils/safeRender'; @@ -25,7 +26,7 @@ export async function logoutCommand(): Promise { title: 'Logout failed', message: 'Failed to remove credentials', }); - process.exitCode = 1; + setExitCode(1); } resolve(); }} diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 83bfae1..3fca28a 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,4 +1,5 @@ import { AgentError } from '../lib/errors'; +import { setExitCode } from '../lib/exit'; import { runCommandHandoff } from '../lib/services/agent-handoff'; const ADD_MCP_PACKAGE = 'add-mcp'; @@ -12,7 +13,7 @@ interface MCPCommandDependencies { export async function mcpCommand(dependencies: MCPCommandDependencies = {}): Promise { const runHandoff = dependencies.runHandoff ?? runCommandHandoff; - const exitProcess = dependencies.exitProcess ?? ((code: number) => process.exit(code)); + const exitProcess = dependencies.exitProcess ?? setExitCode; console.log('Adding Clix MCP Server...'); diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index 1e077ad..fad9f4e 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -3,6 +3,7 @@ import type React from 'react'; import { useCallback } from 'react'; import type { AgentInfo } from '../../lib/agents'; import { getConfigManager } from '../../lib/config/index'; +import { setExitCode } from '../../lib/exit'; import { buildAgentHandoffInvocation, runAgentHandoff } from '../../lib/services/agent-handoff'; import { AgentSelectionService } from '../../lib/services/agent-selection-service'; import { getSkillPrompt } from '../../lib/skills'; @@ -207,18 +208,21 @@ export async function skillCommand(options: SkillCommandOptions): Promise if (!installTaskIds.includes(startTask as InstallTaskId)) { console.error(`Invalid --start-task value: ${startTask}`); console.error(`Allowed values: ${installTaskIds.join(', ')}`); - process.exit(1); + setExitCode(1); + return; } if (skillType !== 'install') { console.error('--start-task is only supported with the install command.'); - process.exit(1); + setExitCode(1); + return; } if (process.env.CLIX_DEV_ENABLE_TASK_OVERRIDE !== '1') { console.error('--start-task is a development-only option.'); console.error('Set CLIX_DEV_ENABLE_TASK_OVERRIDE=1 to enable task override.'); - process.exit(1); + setExitCode(1); + return; } startTaskId = startTask as InstallTaskId; @@ -235,11 +239,13 @@ export async function skillCommand(options: SkillCommandOptions): Promise const context = await gatherPreparationContext(projectPath); if (!context) { console.error('Project not linked. Run "clix login" first.'); - process.exit(1); + setExitCode(1); + return; } const result = await runDoctorPrecheck(context); if (result === 'cancelled') { - process.exit(context.ready ? 0 : 1); + setExitCode(context.ready ? 0 : 1); + return; } preparationContext = context; } @@ -271,5 +277,5 @@ export async function skillCommand(options: SkillCommandOptions): Promise throw new Error(`Failed to launch ${agent.displayName}: ${message}`); } - process.exit(exitCode); + setExitCode(exitCode); } diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts index 5fb2d27..4dc8999 100644 --- a/src/commands/skill/preparation.ts +++ b/src/commands/skill/preparation.ts @@ -94,6 +94,8 @@ export interface IosStatus { bundleId?: string; /** Team ID if detected */ teamId?: string; + /** App name extracted from Xcode project */ + appName?: string; /** App Group ID if configured */ appGroupId?: string; /** Whether entitlements are configured */ @@ -448,6 +450,7 @@ export async function checkFirebaseStatus( interface IosFileStatus { bundleId?: string; teamId?: string; + appName?: string; appGroupId?: string; entitlementsConfigured: boolean; nseConfigured: boolean; @@ -560,6 +563,7 @@ async function detectIosStatusFromFiles( return { bundleId: project.bundleId, teamId: project.teamId, + appName: project.appName, appGroupId: detectedAppGroupId ?? setup?.appGroupId, entitlementsConfigured: hasEntitlements, nseConfigured: hasNse, diff --git a/src/commands/skills.ts b/src/commands/skills.ts index e653a30..e3a4289 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -1,4 +1,5 @@ import { AgentError } from '../lib/errors'; +import { setExitCode } from '../lib/exit'; import { runCommandHandoff } from '../lib/services/agent-handoff'; const DEFAULT_SKILLS_REPOSITORY = 'clix-so/skills'; @@ -10,7 +11,7 @@ interface SkillsCommandDependencies { export async function skillsCommand(dependencies: SkillsCommandDependencies = {}): Promise { const runHandoff = dependencies.runHandoff ?? runCommandHandoff; - const exitProcess = dependencies.exitProcess ?? ((code: number) => process.exit(code)); + const exitProcess = dependencies.exitProcess ?? setExitCode; console.log('Adding Clix Skills...'); diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index f0de482..987bb0a 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -5,6 +5,7 @@ */ import readline from 'node:readline/promises'; +import { setExitCode } from '../lib/exit'; import { executeUninstall, planUninstall, @@ -183,11 +184,12 @@ export async function uninstallCommand(options: UninstallOptions): Promise displayUninstallResult(result, plan); if (!result.success) { - process.exit(1); + setExitCode(1); + return; } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`\nFailed to uninstall: ${errorMessage}\n`); - process.exit(1); + setExitCode(1); } } diff --git a/src/commands/update.ts b/src/commands/update.ts index 486129c..e59e7a7 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -5,6 +5,7 @@ */ import readline from 'node:readline/promises'; +import { setExitCode } from '../lib/exit'; import { executeUpdate, planUpdate, @@ -89,7 +90,8 @@ export async function updateCommand( // Check for update-check errors (network/registry failures) if (plan.error) { console.error(`Failed to check for updates: ${plan.error}`); - process.exit(1); + setExitCode(1); + return; } // No update available @@ -131,11 +133,12 @@ export async function updateCommand( displayUpdateResult(result, plan); if (!result.success) { - process.exit(1); + setExitCode(1); + return; } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`\nFailed to update: ${errorMessage}\n`); - process.exit(1); + setExitCode(1); } } diff --git a/src/commands/whoami.tsx b/src/commands/whoami.tsx index d3a743a..3d24dc6 100644 --- a/src/commands/whoami.tsx +++ b/src/commands/whoami.tsx @@ -1,3 +1,4 @@ +import { setExitCode } from '../lib/exit'; import { safeRender } from '../ui/utils/safeRender'; import { WhoamiUI } from '../ui/WhoamiUI'; @@ -13,7 +14,7 @@ export async function whoamiCommand(): Promise { onComplete={(result) => { unmount(); if (result.status === 'error') { - process.exitCode = 1; + setExitCode(1); } resolve(); }} diff --git a/src/lib/exit.ts b/src/lib/exit.ts new file mode 100644 index 0000000..f0a95e8 --- /dev/null +++ b/src/lib/exit.ts @@ -0,0 +1,10 @@ +/** + * Centralized process exit code management. + * + * All command-level exit code assignments should go through this function + * instead of setting `process.exitCode` directly. The actual `process.exit()` + * call happens once in `cli.tsx` via `main().finally()`. + */ +export function setExitCode(code: number): void { + process.exitCode = code; +} diff --git a/src/lib/ios/extension-templates.ts b/src/lib/ios/extension-templates.ts index d3dbea3..5e7bb2a 100644 --- a/src/lib/ios/extension-templates.ts +++ b/src/lib/ios/extension-templates.ts @@ -12,7 +12,6 @@ import Clix class NotificationService: ClixNotificationServiceExtension { override init() { super.init() - // Register with Clix (replace with your project ID from https://console.clix.so/) register(projectId: "YOUR_PROJECT_ID") } diff --git a/src/lib/services/firebase/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index d5f871d..d7a7f0a 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -14,6 +14,7 @@ import type { CreateIosAppRequest, FirebaseProject, IosApp, + UpdateIosAppRequest, } from './types'; type FirebaseApi = firebase_v1beta1.Firebase; @@ -146,6 +147,8 @@ export class FirebaseApiClient { displayName: a.displayName ?? undefined, bundleId: a.bundleId ?? '', projectId: a.projectId ?? projectId, + teamId: a.teamId ?? undefined, + appStoreId: a.appStoreId ?? undefined, }); } pageToken = res.data.nextPageToken ?? undefined; @@ -211,6 +214,34 @@ export class FirebaseApiClient { displayName: app.displayName ?? undefined, bundleId: app.bundleId ?? '', projectId: app.projectId ?? projectId, + teamId: app.teamId ?? undefined, + appStoreId: app.appStoreId ?? undefined, + }; + } + + async patchIosApp( + projectId: string, + appId: string, + request: UpdateIosAppRequest, + ): Promise { + await this.updateCredentials(); + const updateMask = Object.keys(request) + .filter((key) => request[key as keyof UpdateIosAppRequest] !== undefined) + .join(','); + const res = await this.fb.projects.iosApps.patch({ + name: `projects/${projectId}/iosApps/${appId}`, + updateMask, + requestBody: request, + }); + const app = res.data; + return { + name: app.name ?? '', + appId: app.appId ?? '', + displayName: app.displayName ?? undefined, + bundleId: app.bundleId ?? '', + projectId: app.projectId ?? projectId, + teamId: app.teamId ?? undefined, + appStoreId: app.appStoreId ?? undefined, }; } diff --git a/src/lib/services/firebase/api/index.ts b/src/lib/services/firebase/api/index.ts index 8c86719..8257c52 100644 --- a/src/lib/services/firebase/api/index.ts +++ b/src/lib/services/firebase/api/index.ts @@ -12,4 +12,5 @@ export type { FirebaseProject, IosApp, ServiceAccountJson, + UpdateIosAppRequest, } from './types'; diff --git a/src/lib/services/firebase/api/types.ts b/src/lib/services/firebase/api/types.ts index 15bbe8f..dd81237 100644 --- a/src/lib/services/firebase/api/types.ts +++ b/src/lib/services/firebase/api/types.ts @@ -26,6 +26,8 @@ export interface IosApp { displayName?: string; bundleId: string; projectId: string; + teamId?: string; + appStoreId?: string; } export interface CreateAndroidAppRequest { @@ -36,6 +38,14 @@ export interface CreateAndroidAppRequest { export interface CreateIosAppRequest { bundleId: string; displayName?: string; + teamId?: string; + appStoreId?: string; +} + +export interface UpdateIosAppRequest { + teamId?: string; + appStoreId?: string; + displayName?: string; } export interface ServiceAccountJson { diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index 6bdfec6..eb51576 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -16,6 +16,7 @@ import type { FirebaseProject, IosApp, ServiceAccountJson, + UpdateIosAppRequest, } from './api'; import { type ApiClientCredentials, FirebaseApiClient } from './api'; import { getExpectedPaths } from './detector'; @@ -219,6 +220,26 @@ export class FirebaseDownloader { return api.createIosApp(projectId, request); } + /** + * Update an existing iOS app in a Firebase project. + * + * @param projectId - Firebase project ID + * @param appId - iOS app ID + * @param request - Fields to update (teamId, appStoreId, displayName) + * @returns Updated iOS app + */ + async patchIosApp( + projectId: string, + appId: string, + request: UpdateIosAppRequest, + ): Promise { + if (!(await this.isAuthenticated())) { + throw new Error('Not authenticated. Run OAuth flow first.'); + } + const api = await this.ensureApiClient(); + return api.patchIosApp(projectId, appId, request); + } + /** * Download and save Android config. * diff --git a/src/ui/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx index d2efd68..06d50d1 100644 --- a/src/ui/IosSetupUI.tsx +++ b/src/ui/IosSetupUI.tsx @@ -1,7 +1,9 @@ -import { createInterface } from 'node:readline'; -import { Box, Text, useApp, useInput } from 'ink'; +import { Auth } from '@expo/apple-utils'; +import { Box, Text, useApp, useInput, useStdin } from 'ink'; +import Spinner from 'ink-spinner'; +import TextInput from 'ink-text-input'; import type React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { type AgentContext, analyzeIosProject, @@ -18,7 +20,11 @@ import { syncCapabilities, updateEntitlementsForClix, } from '@/lib/ios'; -import { getRequestContext, loginWithUserCredentialsAsync } from '@/lib/ios/apple-auth'; +import { + getRequestContext, + loginWithUserCredentialsAsync, + type UserAuthContext, +} from '@/lib/ios/apple-auth'; import { FirebaseService } from '@/lib/services/firebase'; import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; import { detectProjectType } from '@/lib/services/project-detector'; @@ -28,7 +34,10 @@ import { useCancelInput } from '@/ui/hooks'; type SetupPhase = | 'analyzing' - | 'authenticating' + | 'auth_apple_id_input' + | 'auth_restoring_session' + | 'auth_password_input' + | 'auth_logging_in' | 'syncing' | 'updating_entitlements' | 'complete' @@ -67,9 +76,17 @@ export interface IosSetupResult { interface IosSetupUIProps { options: IosSetupOptions; + /** Pre-authenticated Apple context from a previous step (e.g., APNS key acquisition). */ + appleAuthContext?: UserAuthContext; onComplete?: (result: IosSetupResult) => void; } +interface AnalysisResult { + project: IosProjectInfo; + bundleId: string; + appGroupId: string; +} + interface SetupState { phase: SetupPhase; projectInfo: IosProjectInfo | null; @@ -80,93 +97,58 @@ interface SetupState { mainTargetEntitlementsLink: MainTargetEntitlementsLinkResult | null; updatedFiles: string[]; errorMessage: string; + appleId: string; + password: string; + authError: string | null; + analysisResult: AnalysisResult | null; } -function promptTerminalInput(message: string, defaultValue?: string): Promise { - const stdin = process.stdin; - const canToggleRawMode = typeof stdin.isTTY === 'boolean' && stdin.isTTY && 'setRawMode' in stdin; - const wasRawModeEnabled = - canToggleRawMode && typeof stdin.isRaw === 'boolean' ? stdin.isRaw : false; - - if (canToggleRawMode && wasRawModeEnabled) { - (stdin as NodeJS.ReadStream).setRawMode(false); - } - - const rl = createInterface({ input: process.stdin, output: process.stderr }); - return new Promise((resolve) => { - const prompt = defaultValue ? `${message} [${defaultValue}] ` : `${message} `; - rl.question(prompt, (answer) => { - rl.close(); - if (canToggleRawMode && wasRawModeEnabled) { - (stdin as NodeJS.ReadStream).setRawMode(true); - } - resolve(answer || defaultValue || ''); - }); - }); +interface EntitlementsUpdateResult { + files: string[]; + entitlementsPath: string; + iosDir: string; } -function promptTerminalConfirm(message: string): Promise { - return promptTerminalInput(`${message} (y/N)`).then( - (answer) => answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes', +function isBundleIdMismatchReason(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes('not available') && + (normalized.includes('identifier') || normalized.includes('bundle id')) && + normalized.includes('apple team') ); } /** - * Sync capabilities with Apple Developer Portal via Apple ID login. - * If the user cancels login, returns null and portal sync is skipped. + * Run project analysis (Phase 1 of setup). */ -async function syncWithPortal( - bundleId: string, - appGroupId: string, - setPhase: (phase: SetupPhase) => void, - setAuthStatusMessage: (message: string) => void, - setPortalSkipReason: (message: string | null) => void, -): Promise { - const isBundleIdMismatchReason = (message: string): boolean => { - const normalized = message.toLowerCase(); - return ( - normalized.includes('not available') && - (normalized.includes('identifier') || normalized.includes('bundle id')) && - normalized.includes('apple team') - ); - }; +async function runAnalysis(options: IosSetupOptions): Promise { + const analysisResult = await analyzeIosProject(process.cwd()); - setPhase('authenticating'); - setPortalSkipReason(null); - setAuthStatusMessage('Waiting for Apple ID/password input in terminal prompt...'); - try { - const userAuth = await loginWithUserCredentialsAsync( - promptTerminalInput, - promptTerminalInput, - promptTerminalConfirm, - ); - const context = getRequestContext(userAuth); - - setPhase('syncing'); - setAuthStatusMessage('Authenticated. Syncing Apple capabilities...'); - return await syncCapabilities(context, bundleId, appGroupId); - } catch (error) { - const reason = - error instanceof Error ? getAppleApiErrorMessage(error) : 'Authentication was not completed'; - if (isBundleIdMismatchReason(reason)) { - throw new Error(reason); - } - setPortalSkipReason(reason); - setAuthStatusMessage(`Portal sync skipped: ${reason}`); - // Portal sync is best-effort: login cancelled, auth failed, or API error - // all result in skipping portal sync and continuing with entitlements. - return null; + if (!analysisResult.success || !analysisResult.project) { + throw new Error(analysisResult.error || 'Failed to analyze iOS project'); } + + const project = analysisResult.project; + const bundleId = options.bundleId || project.bundleId; + const appGroupId = generateAppGroupId(bundleId); + + return { project, bundleId, appGroupId }; } -interface EntitlementsUpdateResult { - files: string[]; - entitlementsPath: string; - iosDir: string; +/** + * Sync capabilities with Apple Developer Portal using authenticated context. + */ +async function runPortalSync( + authContext: UserAuthContext, + bundleId: string, + appGroupId: string, +): Promise { + const context = getRequestContext(authContext); + return await syncCapabilities(context, bundleId, appGroupId); } /** - * Update local entitlements files + * Update local entitlements files. */ async function updateEntitlements( project: IosProjectInfo, @@ -197,12 +179,18 @@ async function updateEntitlements( } /** - * Run the iOS setup process + * Run post-auth setup: portal sync (optional) + entitlements + build settings. */ -async function runSetup( +async function runPostAuth( + analysis: AnalysisResult, + portalSync: CapabilitySyncResult | null, + portalSkipped: boolean, + portalSkipReason: string | null, options: IosSetupOptions, setState: React.Dispatch>, ): Promise { + const { project, bundleId, appGroupId } = analysis; + const result: IosSetupResult = { success: false, entitlementsUpdated: [], @@ -211,40 +199,18 @@ async function runSetup( skippedChecks: [], warnings: [], }, + projectInfo: project, }; - // 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 via Apple ID login - const syncResult = await syncWithPortal( - bundleId, - appGroupId, - (phase) => setState((s) => ({ ...s, phase })), - (message) => setState((s) => ({ ...s, authStatusMessage: message })), - (message) => setState((s) => ({ ...s, portalSkipReason: message })), - ); - if (syncResult) { - setState((s) => ({ ...s, portalResult: syncResult })); - result.portalSync = syncResult; - } else { - setState((s) => ({ ...s, portalSkipped: true })); + if (portalSync) { + setState((s) => ({ ...s, portalResult: portalSync })); + result.portalSync = portalSync; + } else if (portalSkipped) { + setState((s) => ({ ...s, portalSkipped: true, portalSkipReason })); result.changeSummary?.skippedChecks.push('Apple Developer Portal sync'); } - // Phase 3: Update local entitlements files + // Update local entitlements files setState((s) => ({ ...s, phase: 'updating_entitlements' })); const entitlementsResult = await updateEntitlements(project, bundleId, options); @@ -259,7 +225,7 @@ async function runSetup( result.entitlementsUpdated = files; result.changeSummary?.changedFiles.push(...files); - // Phase 4: Ensure main app target links the entitlements file in build settings. + // Ensure main app target links the entitlements file in build settings. const mainTargetLinkResult = await ensureMainTargetEntitlementsLink({ projectPath: project.projectPath, entitlementsPath: entitlementsResult.entitlementsPath, @@ -290,7 +256,6 @@ async function runSetup( // Detect Firebase project ID and Team ID for push setup integration result.bundleId = bundleId; - // Use Team ID from Xcode project settings (DEVELOPMENT_TEAM) if available result.teamId = project.teamId || null; try { const projectType = await detectProjectType(process.cwd()); @@ -300,14 +265,11 @@ async function runSetup( const androidContent = firebaseDetection.android?.content as GoogleServicesJson | undefined; result.firebaseProjectId = iosContent?.PROJECT_ID || androidContent?.project_info?.project_id || null; - // Override Team ID from Firebase config if available (more likely to be correct) if (iosContent?.TEAM_ID) { result.teamId = iosContent.TEAM_ID; } } catch { - // Firebase detection is optional, don't fail if it errors result.firebaseProjectId = null; - // Keep the teamId from project settings if Firebase detection fails } result.success = true; @@ -315,8 +277,13 @@ async function runSetup( return result; } -export const IosSetupUI: React.FC = ({ options, onComplete }) => { +export const IosSetupUI: React.FC = ({ + options, + appleAuthContext, + onComplete, +}) => { const { exit } = useApp(); + const { isRawModeSupported, setRawMode } = useStdin(); const [state, setState] = useState({ phase: 'analyzing', projectInfo: null, @@ -327,8 +294,13 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = mainTargetEntitlementsLink: null, updatedFiles: [], errorMessage: '', + appleId: '', + password: '', + authError: null, + analysisResult: null, }); const [result, setResult] = useState(null); + const authContextRef = useRef(appleAuthContext ?? null); const { phase, @@ -340,10 +312,46 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = mainTargetEntitlementsLink, updatedFiles, errorMessage, + analysisResult, } = state; - // Allow ESC/Ctrl+C to cancel during authenticating phase - useCancelInput(() => exit(), { isActive: phase === 'authenticating' }); + // Re-enable raw mode when entering input phases (expo/apple-utils can leave stdin in cooked mode) + useEffect(() => { + if (!isRawModeSupported) return; + const needsInput = phase === 'auth_apple_id_input' || phase === 'auth_password_input'; + if (needsInput) setRawMode(true); + }, [isRawModeSupported, phase, setRawMode]); + + // Allow ESC/Ctrl+C to skip portal sync during auth phases + const isAuthPhase = + phase === 'auth_apple_id_input' || + phase === 'auth_restoring_session' || + phase === 'auth_password_input' || + phase === 'auth_logging_in'; + + const handleSkipPortalSync = useCallback( + (reason: string) => { + if (!analysisResult) return; + setState((s) => ({ + ...s, + portalSkipped: true, + portalSkipReason: reason, + phase: 'updating_entitlements', + })); + // Run post-auth without portal sync + runPostAuth(analysisResult, null, true, reason, options, setState) + .then((r) => setResult(r)) + .catch((error) => { + const message = error instanceof Error ? getAppleApiErrorMessage(error) : String(error); + setState((s) => ({ ...s, errorMessage: message, phase: 'error' })); + }); + }, + [analysisResult, options], + ); + + useCancelInput(() => handleSkipPortalSync('Authentication cancelled by user'), { + isActive: isAuthPhase, + }); // Handle user input for complete/error phases const handleContinue = useCallback(() => { @@ -362,11 +370,56 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = } }); + // Perform portal sync with given auth context, then run post-auth + const performSyncAndPostAuth = useCallback( + async (authCtx: UserAuthContext, analysis: AnalysisResult) => { + setState((s) => ({ + ...s, + phase: 'syncing', + authStatusMessage: 'Authenticated. Syncing Apple capabilities...', + })); + try { + const syncResult = await runPortalSync(authCtx, analysis.bundleId, analysis.appGroupId); + const r = await runPostAuth(analysis, syncResult, false, null, options, setState); + setResult(r); + } catch (error) { + const reason = + error instanceof Error ? getAppleApiErrorMessage(error) : 'Portal sync failed'; + if (isBundleIdMismatchReason(reason)) { + setState((s) => ({ ...s, errorMessage: reason, phase: 'error' })); + return; + } + // Portal sync is best-effort; continue with entitlements on failure + const r = await runPostAuth(analysis, null, true, reason, options, setState); + setResult(r); + } + }, + [options], + ); + + // Phase 1: Run analysis, then decide auth path useEffect(() => { const execute = async () => { try { - const setupResult = await runSetup(options, setState); - setResult(setupResult); + const analysis = await runAnalysis(options); + setState((s) => ({ + ...s, + projectInfo: analysis.project, + analysisResult: analysis, + })); + + // If pre-authenticated context is provided, try using it directly + if (appleAuthContext) { + try { + await performSyncAndPostAuth(appleAuthContext, analysis); + return; + } catch { + // Auth context expired or invalid; fall through to Ink-based login + } + } + + // No auth context or expired: show Apple login UI + setState((s) => ({ ...s, phase: 'auth_apple_id_input' })); } catch (error) { const message = error instanceof Error ? getAppleApiErrorMessage(error) : String(error); setState((s) => ({ ...s, errorMessage: message, phase: 'error' })); @@ -374,7 +427,104 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = }; execute(); - }, [options]); + }, [options, appleAuthContext, performSyncAndPostAuth]); + + // Handle Apple ID submission + const handleAppleIdSubmit = useCallback(async () => { + const trimmedAppleId = state.appleId.trim(); + if (!trimmedAppleId) { + setState((s) => ({ ...s, authError: 'Apple ID is required' })); + return; + } + + setState((s) => ({ + ...s, + authError: null, + phase: 'auth_restoring_session', + authStatusMessage: 'Checking existing session...', + })); + + Auth.resetInMemoryData(); + + try { + const restoredSession = await Auth.tryRestoringAuthStateFromUserCredentialsAsync( + { username: trimmedAppleId }, + { autoResolveProvider: true }, + ); + + if (restoredSession?.context.teamId && analysisResult) { + // Session restored — build auth context via loginWithUserCredentialsAsync + const promptAppleIdFn = async () => trimmedAppleId; + const promptPasswordFn = async () => ''; + const promptConfirmFn = async () => false; + + try { + const authCtx = await loginWithUserCredentialsAsync( + promptAppleIdFn, + promptPasswordFn, + promptConfirmFn, + {}, + ); + authContextRef.current = authCtx; + await performSyncAndPostAuth(authCtx, analysisResult); + return; + } catch { + // Session restore via loginWithUserCredentials failed; fall through to password + } + } + + // No valid session; need password + setState((s) => ({ ...s, phase: 'auth_password_input' })); + } catch { + setState((s) => ({ ...s, phase: 'auth_password_input' })); + } + }, [state.appleId, analysisResult, performSyncAndPostAuth]); + + // Handle password submission + const handlePasswordSubmit = useCallback(async () => { + if (!state.password) { + setState((s) => ({ ...s, authError: 'Password is required' })); + return; + } + + const trimmedAppleId = state.appleId.trim(); + setState((s) => ({ + ...s, + authError: null, + phase: 'auth_logging_in', + authStatusMessage: 'Authenticating with Apple...', + })); + + try { + const promptAppleIdFn = async () => trimmedAppleId; + const promptPasswordFn = async () => state.password; + const promptConfirmFn = async () => false; + + const authCtx = await loginWithUserCredentialsAsync( + promptAppleIdFn, + promptPasswordFn, + promptConfirmFn, + {}, + ); + authContextRef.current = authCtx; + + if (analysisResult) { + await performSyncAndPostAuth(authCtx, analysisResult); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Authentication failed'; + if (message === 'ABORTED') { + handleSkipPortalSync('Authentication cancelled by user'); + return; + } + setState((s) => ({ + ...s, + authError: message, + password: '', + phase: 'auth_password_input', + })); + } + }, [state.appleId, state.password, analysisResult, performSyncAndPostAuth, handleSkipPortalSync]); return ( @@ -383,18 +533,119 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = {/* Phase: Analyzing */} {phase === 'analyzing' && } - {/* Phase: Authenticating */} - {phase === 'authenticating' && ( + {/* Phase: Apple ID Input */} + {phase === 'auth_apple_id_input' && ( - - {authStatusMessage ? ( - - • {authStatusMessage} + + + Apple Developer Account Login - ) : null} - - • Press Ctrl+C to cancel + + Log in to sync capabilities with Apple Developer Portal. + + {state.authError && ( + + ✗ {state.authError} + + )} + + Apple ID (email): + + + {'> '} + setState((s) => ({ ...s, appleId: v }))} + placeholder="your@email.com" + onSubmit={handleAppleIdSubmit} + /> + + + Enter to continue · Esc/Ctrl+C to skip portal sync + + + + )} + + {/* Phase: Restoring Session */} + {phase === 'auth_restoring_session' && ( + + + + + + + {authStatusMessage} + + + )} + + {/* Phase: Password Input */} + {phase === 'auth_password_input' && ( + + + + + Apple Developer Account Login + + + Apple ID: {state.appleId.trim()} + + {state.authError && ( + + ✗ {state.authError} + + )} + + Password: + + + {'> '} + setState((s) => ({ ...s, password: v }))} + placeholder="••••••••" + mask="*" + onSubmit={handlePasswordSubmit} + /> + + + Your password is stored securely in your local Keychain + + + Enter to continue · Esc/Ctrl+C to skip portal sync + + + + )} + + {/* Phase: Logging In */} + {phase === 'auth_logging_in' && ( + + + + + + + {authStatusMessage} + + + If prompted, check your device for 2FA code )} diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx index deadb68..ec2621e 100644 --- a/src/ui/components/FirebaseConfigFilesSetup.tsx +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -3,13 +3,14 @@ import SelectInput from 'ink-select-input'; import Spinner from 'ink-spinner'; import TextInput from 'ink-text-input'; import type React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { AndroidApp, FirebaseDetectionResult, FirebaseProject, IosApp, } from '@/lib/services/firebase'; +import type { FirebaseDownloader } from '@/lib/services/firebase/downloader'; import { OAUTH_CALLBACK_CONFIG } from '@/lib/utils/oauth'; import { useCancelInput } from '@/ui/hooks'; import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; @@ -424,16 +425,18 @@ function NoAppsFoundPhase({ function CreateAppInputPhase({ platform, defaultIdentifier, + defaultDisplayName, onSubmit, onCancel, }: { platform: 'android' | 'ios'; defaultIdentifier?: string; + defaultDisplayName?: string; onSubmit: (identifier: string, displayName?: string) => void; onCancel: () => void; }): React.ReactElement { const [identifier, setIdentifier] = useState(defaultIdentifier ?? ''); - const [displayName, setDisplayName] = useState(''); + const [displayName, setDisplayName] = useState(defaultDisplayName ?? ''); const [stage, setStage] = useState<'identifier' | 'display_name'>('identifier'); useCancelInput(onCancel); @@ -644,6 +647,8 @@ export { DetectingPhase as FirebaseConfigDetectingTask, DownloadingPhase as FirebaseConfigDownloadingTask, ErrorPhase as FirebaseConfigErrorTask, + IosAppUpdateConfirmationPhase as FirebaseIosAppUpdateConfirmation, + IosAppUpdatePhase as FirebaseIosAppUpdateTask, NoAppsFoundPhase as FirebaseConfigNoAppsFoundTask, NoProjectsPhase as FirebaseConfigNoProjectsTask, ProjectSelectorPhase as FirebaseConfigProjectSelectorTask, @@ -701,3 +706,120 @@ function ErrorPhase({ ); } + +function IosAppUpdateConfirmationPhase({ + currentTeamId, + newTeamId, + appBundleId, + onConfirm, + onSkip, +}: { + currentTeamId?: string; + newTeamId: string; + appBundleId: string; + onConfirm: () => void; + onSkip: () => void; +}): React.ReactElement { + useCancelInput(onSkip); + + const items = [ + { label: 'Update', value: 'update' }, + { label: 'Skip', value: 'skip' }, + ]; + + return ( + + + + Firebase iOS App Team ID Mismatch + + + + The Firebase iOS app ({appBundleId}) has a different Team ID than the APNS key. + Current: {currentTeamId || '(none)'} + APNS Key: {newTeamId} + + + Update Firebase iOS app Team ID to match? + + { + if (item.value === 'update') { + onConfirm(); + } else { + onSkip(); + } + }} + /> + + ↑↓ to navigate · Enter to select · Esc/Ctrl+C to skip + + + ); +} + +function IosAppUpdatePhase({ + downloader, + projectId, + appId, + newTeamId, + onComplete, + onError, +}: { + downloader: FirebaseDownloader; + projectId: string; + appId: string; + newTeamId: string; + onComplete: () => void; + onError: (error: string) => void; +}): React.ReactElement { + useEffect(() => { + let cancelled = false; + + const update = async () => { + try { + await downloader.patchIosApp(projectId, appId, { teamId: newTeamId }); + if (!cancelled) onComplete(); + } catch (err) { + if (!cancelled) { + onError(err instanceof Error ? err.message : 'Failed to update Firebase iOS app'); + } + } + }; + + void update(); + + return () => { + cancelled = true; + }; + }, [appId, downloader, newTeamId, onComplete, onError, projectId]); + + return ( + + + Updating Firebase iOS App + + + + + + Updating Team ID to {newTeamId}... + + + ); +} diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx index 4e2f68e..cac334a 100644 --- a/src/ui/components/InstallPreparationUI.tsx +++ b/src/ui/components/InstallPreparationUI.tsx @@ -71,6 +71,8 @@ import { FirebaseConfigNoProjectsTask, FirebaseConfigProjectSelectorTask, FirebaseConfigStatusTask, + FirebaseIosAppUpdateConfirmation, + FirebaseIosAppUpdateTask, platformNeedsAndroidWithUnknown as firebaseConfigNeedsAndroid, platformNeedsIosWithUnknown as firebaseConfigNeedsIos, hasValidFirebaseConfigFiles as hasValidFirebaseConfigTaskFiles, @@ -139,6 +141,8 @@ type InstallLeafTaskId = | 'apns_input' | 'apns_select_firebase_project' | 'apns_registering' + | 'apns_confirm_update_firebase_ios_app' + | 'apns_updating_firebase_ios_app' | 'firebase_service_account_detecting' | 'firebase_service_account_checking_sender_config' | 'firebase_service_account_registered' @@ -231,6 +235,7 @@ interface FirebaseConfigState { androidApps: AndroidApp[]; iosApps: IosApp[]; selectedAndroidApp: AndroidApp | null; + selectedIosApp: IosApp | null; downloadingPlatform: 'android' | 'ios' | 'both'; noAppsContext: FirebaseConfigNoAppsContext | null; creatingAppPlatform: 'android' | 'ios'; @@ -704,6 +709,7 @@ function createInitialFirebaseConfigState(): FirebaseConfigState { androidApps: [], iosApps: [], selectedAndroidApp: null, + selectedIosApp: null, downloadingPlatform: 'both', noAppsContext: null, creatingAppPlatform: 'android', @@ -1065,10 +1071,23 @@ export function InstallPreparationUI({ const resetTaskRuntimeState = useCallback((preparationContext?: PreparationContext) => { setActiveLeafTaskId(null); - setFirebaseConfigState(createInitialFirebaseConfigState()); + setFirebaseConfigState((prev) => { + const fresh = createInitialFirebaseConfigState(); + // Preserve selected project/iOS app for post-APNS teamId mismatch detection + return { + ...fresh, + selectedProject: prev.selectedProject, + selectedIosApp: prev.selectedIosApp, + }; + }); setFirebaseServiceAccountState(createInitialFirebaseServiceAccountState()); setNotificationExtensionState(createInitialNotificationExtensionState()); - setApnsState(preparationContext ? createInitialApnsState(preparationContext) : null); + setApnsState((prev) => { + if (!preparationContext) return null; + const fresh = createInitialApnsState(preparationContext); + // Preserve acquisition result (including authContext) across task transitions + return prev?.acquisition ? { ...fresh, acquisition: prev.acquisition } : fresh; + }); }, []); const beginTask = useCallback( @@ -1260,7 +1279,11 @@ export function InstallPreparationUI({ const downloadingPlatform: 'android' | 'ios' | 'both' = androidApp && iosApp ? 'both' : androidApp ? 'android' : 'ios'; - setFirebaseConfigState((prev) => ({ ...prev, downloadingPlatform })); + setFirebaseConfigState((prev) => ({ + ...prev, + downloadingPlatform, + selectedIosApp: iosApp ?? prev.selectedIosApp, + })); setActiveLeafTaskId('firebase_config_downloading'); try { @@ -1563,11 +1586,13 @@ export function InstallPreparationUI({ (await firebaseConfigDownloader.createIosApp(selectedProject.projectId, { bundleId: normalizedIdentifier, displayName, + teamId: context?.ios.teamId, })); setFirebaseConfigState((prev) => ({ ...prev, iosApps: existingIosApp ? iosApps : [...iosApps, iosApp], + selectedIosApp: iosApp, })); await startFirebaseConfigDownload( @@ -1769,7 +1794,7 @@ export function InstallPreparationUI({ await handleFirebaseConfigCreateApp( isCreateIosAction ? 'ios' : 'android', targetIdentifier, - undefined, + isCreateIosAction ? context?.ios.appName : undefined, targetProject, ); return; @@ -1806,6 +1831,7 @@ export function InstallPreparationUI({ }, [ activeLeafTaskId, activeTaskId, + context?.ios.appName, context?.ios.bundleId, context?.firebase.actualAndroidPackageName, firebaseConfigDownloader, @@ -2716,6 +2742,7 @@ export function InstallPreparationUI({ { void handleFirebaseConfigCreateApp('ios', identifier, displayName); }} @@ -2845,6 +2872,20 @@ export function InstallPreparationUI({ context={registrationContext} selectedProject={currentApnsState.selectedProject} onComplete={() => { + const apnsTeamId = registrationContext.pushKey?.teamId; + const firebaseIosApp = firebaseConfigState.selectedIosApp; + const firebaseProject = firebaseConfigState.selectedProject; + + if ( + apnsTeamId && + firebaseIosApp && + firebaseProject && + firebaseIosApp.teamId !== apnsTeamId + ) { + setActiveLeafTaskId('apns_confirm_update_firebase_ios_app'); + return; + } + void applyTaskCompletion({ apns: { needed: context.apns.needed, @@ -2861,6 +2902,56 @@ export function InstallPreparationUI({ ✗ APNS key data is missing. Retry this step. ))} + + {activeLeafTaskId === 'apns_confirm_update_firebase_ios_app' && ( + setActiveLeafTaskId('apns_updating_firebase_ios_app')} + onSkip={() => { + void applyTaskCompletion({ + apns: { + needed: context.apns.needed, + registeredWithFirebase: true, + keyId: registrationContext.pushKey?.apnsKeyId, + teamId: registrationContext.pushKey?.teamId, + }, + }); + }} + /> + )} + + {activeLeafTaskId === 'apns_updating_firebase_ios_app' && + firebaseConfigState.selectedProject && + firebaseConfigState.selectedIosApp && ( + { + void applyTaskCompletion({ + apns: { + needed: context.apns.needed, + registeredWithFirebase: true, + keyId: registrationContext.pushKey?.apnsKeyId, + teamId: registrationContext.pushKey?.teamId, + }, + }); + }} + onError={() => { + void applyTaskCompletion({ + apns: { + needed: context.apns.needed, + registeredWithFirebase: true, + keyId: registrationContext.pushKey?.apnsKeyId, + teamId: registrationContext.pushKey?.teamId, + }, + }); + }} + /> + )} ); } @@ -2947,6 +3038,7 @@ export function InstallPreparationUI({ subtitle="Configure required iOS entitlements for push notifications." /> { if (!result.success) { handleTaskBackToStatus(result.error || undefined); diff --git a/src/ui/components/ios-setup/IosEntitlementsTask.tsx b/src/ui/components/ios-setup/IosEntitlementsTask.tsx index f4191b5..6f6ef88 100644 --- a/src/ui/components/ios-setup/IosEntitlementsTask.tsx +++ b/src/ui/components/ios-setup/IosEntitlementsTask.tsx @@ -1,12 +1,17 @@ import type React from 'react'; +import type { UserAuthContext } from '@/lib/ios/apple-auth'; import { type IosSetupResult, IosSetupUI } from '@/ui/IosSetupUI'; export type IosEntitlementsTaskResult = IosSetupResult; interface IosEntitlementsTaskProps { + appleAuthContext?: UserAuthContext; onComplete: (result: IosEntitlementsTaskResult) => void; } -export const IosEntitlementsTask: React.FC = ({ onComplete }) => { - return ; +export const IosEntitlementsTask: React.FC = ({ + appleAuthContext, + onComplete, +}) => { + return ; }; diff --git a/src/ui/components/push-setup/PushSetupTasks.tsx b/src/ui/components/push-setup/PushSetupTasks.tsx index 5e4a491..7a6b150 100644 --- a/src/ui/components/push-setup/PushSetupTasks.tsx +++ b/src/ui/components/push-setup/PushSetupTasks.tsx @@ -14,7 +14,7 @@ import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { openBrowser } from '@/lib/auth/browser'; import { PROJECT_CONFIG_DIR } from '@/lib/config/project-config-schema'; -import { analyzeIosProject } from '@/lib/ios'; +import { analyzeIosProject, type UserAuthContext } from '@/lib/ios'; import { APNS_KEY_CREATION_STEPS, FIREBASE_UPLOAD_STEPS, @@ -27,7 +27,7 @@ import { import { FirebaseDownloader, type FirebaseProject, FirebaseService } from '@/lib/services/firebase'; import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; import { detectProjectType } from '@/lib/services/project-detector'; -import { isCtrlCInput, useCancelInput } from '@/ui/hooks'; +import { useCancelInput } from '@/ui/hooks'; import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; import { AppleLoginUI } from '../AppleLoginUI'; import { normalizeInputFilePath } from '../file-input-utils'; @@ -48,6 +48,8 @@ interface DetectionTaskOptions { export interface ApnsKeyAcquisitionResult { pushKey: NonNullable; p8FilePath: string | null; + /** Apple auth context for reuse by downstream tasks (e.g., portal sync). */ + authContext?: UserAuthContext; } interface ErrorPhaseProps { @@ -497,6 +499,7 @@ function P8InputPhase({ const [error, setError] = useState(null); const [extractedKeyId, setExtractedKeyId] = useState(null); const [prefilledTeamId] = useState(suggestedTeamId || null); + const p8PathRef = useRef(''); useEffect(() => { const files = findP8Files(); @@ -506,12 +509,44 @@ function P8InputPhase({ } }, []); - useInput((input, key) => { - if (key.escape || isCtrlCInput(input, key)) { - onCancel(); - } + // Disable ESC during text input stages to prevent false triggers from + // escape sequences in pasted text (e.g., macOS file drag-and-drop). + useCancelInput(onCancel, { + handleEsc: stage === 'p8_select', }); + // Custom input handler for file path. ink-text-input's TextInput has a race + // condition with multi-chunk paste: it calculates new values against stale + // React props, so only the last chunk survives. Using a ref to accumulate + // input avoids this because refs update synchronously across chunks. + useInput( + (input, key) => { + if (key.return) { + handleP8PathSubmit(); + return; + } + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.tab) return; + + if (key.backspace || key.delete) { + if (p8PathRef.current.length > 0) { + p8PathRef.current = p8PathRef.current.slice(0, -1); + setP8Path(p8PathRef.current); + } + return; + } + + if (input) { + // Strip ANSI CSI sequences (e.g., bracketed paste markers [200~, [201~) + const cleaned = input.replace(/\[\d+[~^$]/g, ''); + if (cleaned) { + p8PathRef.current += cleaned; + setP8Path(p8PathRef.current); + } + } + }, + { isActive: stage === 'p8_path' }, + ); + const handleFileSelect = useCallback((item: { label: string; value: string }) => { if (item.value === 'manual') { setStage('p8_path'); @@ -535,18 +570,20 @@ function P8InputPhase({ }, []); const handleP8PathSubmit = useCallback(() => { - if (!p8Path.trim()) { + const currentPath = p8PathRef.current; + if (!currentPath.trim()) { setError('P8 file path is required'); return; } - const normalizedP8Path = normalizeInputFilePath(p8Path); + const normalizedP8Path = normalizeInputFilePath(currentPath); const result = validateP8File(normalizedP8Path); if (!result.valid) { setError(result.error || 'Invalid P8 file'); return; } + p8PathRef.current = normalizedP8Path; setP8Path(normalizedP8Path); setError(null); if (result.suggestedKeyId) { @@ -554,7 +591,7 @@ function P8InputPhase({ setKeyId(result.suggestedKeyId); } setStage('key_id'); - }, [p8Path]); + }, []); const handleKeyIdSubmit = useCallback(() => { const keyIdError = getKeyIdError(keyId.trim()); @@ -630,12 +667,17 @@ function P8InputPhase({ {'>'} - + {p8Path ? ( + + {p8Path} + + + ) : ( + + D + {'rag .p8 file here, or enter path manually'} + + )} )} @@ -712,7 +754,7 @@ function P8InputPhase({ {stage === 'p8_select' ? '↑↓ navigate · Enter select · Esc/Ctrl+C cancel' - : 'Enter to continue · Esc/Ctrl+C to cancel'} + : 'Enter to continue · Ctrl+C to cancel'} @@ -732,6 +774,7 @@ export const ApnsKeyAcquisitionTask: React.FC<{ const handleAppleLoginSuccess = useCallback( (result: { + authContext: UserAuthContext; pushKey: { apnsKeyId: string; apnsKeyP8: string; teamId: string; teamName?: string }; }) => { const p8FileName = `AuthKey_${result.pushKey.apnsKeyId}.p8`; @@ -770,6 +813,7 @@ export const ApnsKeyAcquisitionTask: React.FC<{ teamId: result.pushKey.teamId, }, p8FilePath: savedPath, + authContext: result.authContext, }); }, [onComplete, projectPath], From 2b93ee3843e2ece30a178be979961d014fe09ed4 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 23 Feb 2026 20:41:56 -0800 Subject: [PATCH 30/30] release: Bump version to 1.1.2. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 27a4985..8956587 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clix-so/clix-cli", - "version": "1.1.2-beta.3", + "version": "1.1.2", "description": "A CLI tool for integrating and managing the Clix SDK in your mobile projects", "type": "module", "bin": {