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/.gitignore b/.gitignore index 8a1acbe..6ac564d 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,8 @@ android/app/google-services.json # Clix CLI local config .clix/ + +.omc +.ralph-tui +llm-council +plan.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 13dddb9..2a088e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,20 @@ 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. +- 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. + ## Commands ```bash @@ -14,11 +28,31 @@ 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. +- **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,30 +75,25 @@ 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`: +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. -| 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 | +### Chat UI -Key files: +`src/ui/chat/ChatApp.tsx` - Main component with context provider. State split into focused hooks under `src/ui/chat/hooks/`. -- `README.md` - User-facing documentation (concise) -- `llms.txt` - AI assistant reference (comprehensive) +### Install Preparation Tasks -### Chat UI +`src/ui/components/InstallPreparationUI.tsx` orchestrates setup as sequential 1-depth tasks. -`src/ui/chat/ChatApp.tsx` - Main component with context provider. State split into focused hooks under `src/ui/chat/hooks/`. +- 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 @@ -83,11 +112,48 @@ Key files: ## 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'); +} +``` -**Prefer plain objects with interfaces** over classes where possible. Use ES module exports for encapsulation. +**No non-null assertions** — use guard checks: + +```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 @@ -120,75 +186,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` +**New components**: Import `useCancelInput`, add cancel handler with `isActive` condition. For selector-based components, extend `GenericSelector` (has built-in cancel). -### Safe Rendering (Anti-Flicker) +### Safe Rendering -**Always use `safeRender()` instead of Ink's `render()`** to prevent terminal flickering. - -**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`. - -**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**: +**Path Alias**: `@/` = `src/` directory (e.g., `import { Header } from '@/ui/components/Header'`) -- `@/` = `src/` directory -- Example: `import { Header } from '@/ui/components/Header'` +**Directory rules**: -**Shared Components (`@/ui/components/`)**: - -- `ToolCallDisplay` - Tool execution status (used by both modes) -- `StatusMessage` - Status messages (loading/success/error) -- `Header` - Simple title header -- `AgentSelector`, `NoAgentGuide`, etc. - -**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**: @@ -200,62 +227,69 @@ 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 +- 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. -**After every code change**, run the full test suite and fix ALL issues: +**Mocking**: Use `mock.module()` for module mocks. Keep tests deterministic — avoid network calls. -```bash -bun run check && bun test # Unit tests -bun run build && bun test tests/e2e/ # E2E tests (requires build first) +```typescript +import { describe, expect, mock, test } from 'bun:test'; + +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** 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 diff --git a/README.md b/README.md index ac12624..2d9d063 100644 --- a/README.md +++ b/README.md @@ -1,419 +1,141 @@ # 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 +- 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 -### 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. +See [UNINSTALL.md](UNINSTALL.md) for removal instructions. ## Prerequisites -- **Node.js 20+** (Bun v1.0+ is optional for faster development) +- **Node.js 20+** - **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 | +| 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, we recommend starting with **Gemini CLI** or **GitHub Copilot** which offer generous free tiers. +> **Tip:** If you don't have an active subscription, start 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**: - -``` -> How do I integrate Clix SDK into my iOS project? -> /debug -> /integration +clix agent # Select an AI agent +clix install # Install Clix SDK into your project +clix doctor # Diagnose SDK integration issues ``` ## Commands -### `clix` (default) - -Start an interactive chat session with your configured AI agent. - -```bash -clix -``` - -**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:** - -- `/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. - -```bash -# List available agents -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 -``` +| 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 | -**Options:** +### `clix install [--start-task ]` -- `--platform ` - Target platform (ios, android, react-native, flutter) +Runs a two-phase install pipeline: -**iOS Dependency Managers:** +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 -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` +The `--start-task` flag is development-only and requires `CLIX_DEV_ENABLE_TASK_OVERRIDE=1`. ### `clix doctor` -Analyze SDK integration status in your project. - -```bash -clix doctor -``` - -### `clix ios-setup` - -Configure iOS capabilities, Notification Service Extension (NSE), and APNS key for the Clix SDK. - -```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. - -### `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` - -Event tracking setup with `Clix.trackEvent()`. - -``` -> clix -> /event-tracking -``` - -#### `user-management` - -User management and identification setup. - -``` -> clix -> /user-management -``` - -#### `personalization` - -Personalization templates setup. - -``` -> clix -> /personalization -``` - -#### `api-triggered-campaigns` - -API-triggered campaign setup. - -``` -> clix -> /api-triggered-campaigns -``` - -### `clix debug ` - -Interactive debugging assistant for troubleshooting issues. - -```bash -clix debug "Push notifications not working on iOS" -``` - -### `clix install-mcp [agent]` - -Install Clix MCP Server for enhanced AI assistance. +Hands off to the selected AI agent CLI with a diagnostic prompt. The agent analyzes SDK integration status, dependency health, and configuration issues. -```bash -# Auto-detect agent -clix install-mcp - -# Install for specific agent -clix install-mcp claude -clix install-mcp codex -``` - -## Slash Commands +### `clix mcp` -Use these commands within the interactive chat (`clix`): +Installs the Clix MCP Server via `npx add-mcp @clix-so/clix-mcp-server@latest --name clix`. -### Autonomous Commands +### `clix skills` -| Command | Aliases | Description | -|---------|---------|-------------| -| `/install` | | Autonomous SDK installation | -| `/doctor` | | Check SDK integration status | -| `/debug` | | Interactive debugging assistant | -| `/ios-setup` | `/capabilities`, `/ios-capabilities` | Configure iOS capabilities and NSE | +Installs the Clix Skills via `npx skills add clix-so/skills` and transfers CLI control. -### Interactive Skills +### `clix update [--dry-run] [--force]` -| 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 | +Checks for CLI updates and applies them. Use `--dry-run` to preview without applying, `--force` to skip confirmation. -### System +### `clix uninstall [--keep-config] [--keep-state] [--dry-run] [--force]` -| 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 | -| `/firebase` | | Check and configure Firebase credentials | -| `/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 | +Removes Clix CLI. Use `--keep-config` or `--keep-state` to preserve local data. -## Interactive Features +## Install Preparation Tasks -### Debug Assistant +`clix install` enforces the following order for required preparation tasks: -The `/debug` command provides interactive problem diagnosis: - -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] -``` +1. Firebase Configuration Files +2. Firebase Service Account +3. APNS Key for Firebase +4. iOS Entitlements +5. Notification Service Extension -### 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 +Runtime SDK installation executes only after all required preparation steps are complete. ## Development -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development guide. - -### Quick Start +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development guide. ```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 +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 ``` -## Contributing - -Pull requests and issues are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for: - -- Development setup and workflow -- Code contribution process -- Testing guidelines -- Release process - -## License - -MIT - ## Links -- [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 +- [Issue Tracker](https://github.com/clix-so/clix-cli/issues) +- [LLMs.txt](llms.txt) — Documentation for AI assistants ---- +## License -Made with love by the Clix team +MIT diff --git a/bun.lock b/bun.lock index b310a85..86dac14 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,9 @@ "": { "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", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", "ink": "^6.6.0", @@ -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", @@ -61,15 +60,11 @@ "@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=="], - "@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=="], + "@googleapis/firebase": ["@googleapis/firebase@12.0.1", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-ODFU7EbB2G/WFsXiNCXjRgJPRxT5JYh3ydznNdsVng/1SnWmQZK8Bgufvxq6ZWnNDqZJTtNUqAwjbMBoP6N3MA=="], "@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=="], @@ -157,7 +152,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=="], @@ -189,8 +184,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=="], @@ -209,15 +202,15 @@ "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=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], "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=="], @@ -227,10 +220,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=="], @@ -261,32 +250,32 @@ "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=="], - - "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=="], - "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=="], "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=="], "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=="], + "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=="], @@ -325,8 +314,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=="], @@ -339,6 +326,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,7 +342,9 @@ "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "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=="], "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], @@ -359,7 +352,9 @@ "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=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -369,16 +364,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=="], @@ -387,8 +378,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=="], @@ -397,30 +386,20 @@ "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-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=="], - "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=="], @@ -445,8 +424,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=="], @@ -463,14 +440,12 @@ "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=="], + "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=="], @@ -495,8 +470,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=="], @@ -507,14 +480,12 @@ "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=="], - "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=="], @@ -553,21 +524,19 @@ "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=="], "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=="], "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=="], @@ -581,12 +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=="], - - "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=="], @@ -599,6 +562,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=="], @@ -619,18 +590,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=="], @@ -645,8 +614,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=="], @@ -661,20 +628,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=="], - "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], "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=="], @@ -695,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=="], @@ -721,120 +682,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-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "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=="], - - "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 7ab8cf0..66d0275 100644 --- a/llms.txt +++ b/llms.txt @@ -1,1308 +1,81 @@ # 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 +Supported AI agents: Claude Code, Codex, Gemini CLI, OpenCode, Cursor, GitHub Copilot. -## Installation +Primary commands: +- `clix install` +- `clix doctor` -### via npm (Recommended) -```bash -npm install -g @clix-so/clix-cli -``` +`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. -### via Bun -```bash -bun add -g @clix-so/clix-cli -``` +## Execution Model -### via Homebrew (macOS) -```bash -brew tap clix-so/clix-cli -brew install clix -``` +1. CLI resolves command and validates flags. +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`). -### via Install Script -```bash -curl -fsSL https://clix.sh/install | bash -``` +No chat wrapping, slash command flow, or interactive skill mode is required. -## Prerequisites +## Skills Policy -- **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) +- Legacy package-based skill integration is removed from runtime behavior. +- Clix only supports local command prompts (`install`, `doctor`) for execution. +- `project-build` is internal-only and not exposed as a CLI command. ## Commands -### `clix` (Primary Command) - -Launches an interactive chat interface with your configured AI agent. This is the main command for interacting with Clix CLI. - -```bash -clix -``` - -**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 - -**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 - -**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 - -**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 [--start-task ]` +Runs install preparation UI, then hands off to agent CLI interactive execution. -### `clix install` - -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. +Install preparation task order: +1. Firebase Configuration Files +2. Firebase Service Account +3. APNS Key for Firebase +4. iOS Entitlements +5. Notification Service Extension ### `clix doctor` +Runs diagnostics via interactive handoff to agent CLI. -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 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 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. All 20 slash commands are organized into three categories: - -#### Autonomous Commands (4 commands) - -These can be run from command-line (`clix `) or chat mode: - -- `/install` - Autonomous SDK installation with automatic file modifications -- `/doctor` - Analyze SDK integration status (automatic scan, JSON output) -- `/debug` - Interactive debugging assistant (asks for problem description) -- `/ios-setup` (aliases: `/capabilities`, `/ios-capabilities`) - Configure iOS capabilities and Notification Service Extension (NSE) - -#### 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 (11 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 -- `/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 -- `/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-setup` - iOS Setup, Capabilities, NSE & APNS Key Configuration - -**Category:** Autonomous Command - -**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 - -**What is automated:** -- Creating/modifying entitlements files -- Reading project configuration -- Generating NSE source files (NotificationService.swift, Info.plist) -- P8 file format validation - -**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 - -**Example:** -``` -> /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] -``` - -### 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 - /firebase - Check and configure Firebase credentials - /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% -``` - -#### `/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 - -**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] - ``` +### `clix mcp` +Hands off to `npx add-mcp @clix-so/clix-mcp-server@latest --name clix` to install Clix MCP server. -6. **Switch agents when needed** - ``` - # Try different agent for fresh perspective: - /agent gemini - [Same conversation, different agent approach] - ``` +### `clix skills` +Hands off to `npx skills add clix-so/skills` and transfers CLI control. -## 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. **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 -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 `/ios-setup` to configure Push Notifications, App Groups, and Notification Service Extension (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` +- Agent handoff service: `src/lib/services/agent-handoff.ts` +- 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. +- 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 766bf24..8956587 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", "description": "A CLI tool for integrating and managing the Clix SDK in your mobile projects", "type": "module", "bin": { @@ -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,9 +47,9 @@ }, "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", "asciify-image": "^0.1.10", "google-auth-library": "^10.5.0", "ink": "^6.6.0", @@ -57,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/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..5107440 100644 --- a/scripts/embed-skills.ts +++ b/scripts/embed-skills.ts @@ -1,392 +1,106 @@ #!/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. - */ - -/** - * Skill metadata interface. + * Only local command prompts are embedded. */ -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. - */ -export const EMBEDDED_SKILL_METADATA: SkillMetadata[] = [ -${metadataEntries} -]; - -/** - * 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); -} - -/** - * 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); -} `; } -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 656f9fa..67a69bd 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,61 +1,43 @@ 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 { doctorCommand } from './commands/doctor'; +import { installCommand } from './commands/install'; import { loginCommand } from './commands/login'; import { logoutCommand } from './commands/logout'; -import { resumeCommand } from './commands/resume'; +import { mcpCommand } from './commands/mcp'; import { setupCommand } from './commands/setup'; -import { skillCommand } from './commands/skill/index'; +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'; -import { - getValidMCPAgents, - 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} - firebase Check and configure Firebase credentials - debug Interactive debugging assistant - install-mcp [agent] Install Clix MCP Server - resume Resume a previous session + 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 Skills uninstall Uninstall Clix CLI from your system update Check for available updates Options --help Show this help message --version Show version number - --platform Target platform (ios, android, react-native, flutter) Examples $ clix @@ -65,13 +47,10 @@ ${localSkillCommands} $ clix whoami $ clix agent $ clix agent claude - $ clix resume $ clix install $ clix doctor - $ clix firebase - $ clix debug "Push notifications not working on iOS" - $ clix install-mcp - $ clix install-mcp claude + $ clix mcp + $ clix skills `; } @@ -80,13 +59,9 @@ 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: { + startTask: { type: 'string', }, - interactive: { - type: 'boolean', - default: false, - }, keepConfig: { type: 'boolean', default: false, @@ -104,35 +79,12 @@ 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', - }, }, }); 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)) { @@ -162,29 +114,29 @@ async function main() { break; } - case 'debug': { - const problem = cli.input.slice(1).join(' '); - await debugCommand({ problem }); + case 'install': { + const startTask = cli.flags.startTask; + await installCommand({ startTask }); break; } - case 'resume': { - await resumeCommand(); + case 'doctor': { + await doctorCommand(); break; } - case 'install-mcp': { - const agentInput = cli.input[1]; - const validAgents = getValidMCPAgents(); - if (agentInput && !isValidMCPAgent(agentInput)) { - console.error(`Unknown agent: ${agentInput}`); - console.error(`Supported agents: ${validAgents.join(', ')}`); - process.exit(1); + case 'mcp': + if (cli.input[1]) { + console.error('mcp command does not accept positional arguments.'); + setExitCode(1); + break; } - const agent = agentInput as MCPTargetAgent | undefined; - await installMCPCommand({ agent }); + await mcpCommand(); + break; + + case 'skills': + await skillsCommand(); break; - } case 'update': case 'upgrade': @@ -203,10 +155,6 @@ async function main() { }); break; - case 'firebase': - await firebaseCommand(); - break; - case 'setup': { const status = await checkFirstRun(); if (status.needsSetup) { @@ -217,52 +165,24 @@ 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 ?? '')) { - const platform = cli.flags.platform as - | 'ios' - | 'android' - | 'react-native' - | 'flutter' - | undefined; - await skillCommand({ action: command, platform }); - } 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); + setExitCode(1); } else { - // No command provided - start interactive chat TUI - await chatCommand(); + // No command provided - show command help + cli.showHelp(); } break; } } 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/__tests__/mcp.test.ts b/src/commands/__tests__/mcp.test.ts new file mode 100644 index 0000000..3c60a05 --- /dev/null +++ b/src/commands/__tests__/mcp.test.ts @@ -0,0 +1,38 @@ +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'; + +describe('mcpCommand', () => { + beforeEach(() => { + runCommandHandoffMock.mockReset(); + runCommandHandoffMock.mockResolvedValue(0); + exitProcessMock.mockReset(); + }); + + test('hands off with fixed add-mcp arguments', async () => { + await mcpCommand({ runHandoff: runCommandHandoffMock, exitProcess: exitProcessMock }); + + 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([ + 'add-mcp', + '@clix-so/clix-mcp-server@latest', + '--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 new file mode 100644 index 0000000..a5e51ef --- /dev/null +++ b/src/commands/__tests__/skills.test.ts @@ -0,0 +1,29 @@ +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'; + +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, exitProcess: exitProcessMock }); + + expect(runCommandHandoffMock).toHaveBeenCalledTimes(1); + expect(runCommandHandoffMock).toHaveBeenCalledWith({ + command: 'npx', + args: ['skills', 'add', 'clix-so/skills'], + workingDirectory: process.cwd(), + displayName: 'Skills CLI', + }); + expect(exitProcessMock).toHaveBeenCalledTimes(1); + expect(exitProcessMock).toHaveBeenCalledWith(0); + }); +}); 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..1a1aaa4 --- /dev/null +++ b/src/commands/doctor.tsx @@ -0,0 +1,7 @@ +import { skillCommand } from './skill/index'; + +export async function doctorCommand(): Promise { + await skillCommand({ + action: 'doctor', + }); +} 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/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/install.tsx b/src/commands/install.tsx index 0cded99..e042843 100644 --- a/src/commands/install.tsx +++ b/src/commands/install.tsx @@ -1,74 +1,12 @@ -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 { + 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', + startTask: options.startTask, }); } 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/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 new file mode 100644 index 0000000..3fca28a --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,34 @@ +import { AgentError } from '../lib/errors'; +import { setExitCode } from '../lib/exit'; +import { runCommandHandoff } from '../lib/services/agent-handoff'; + +const ADD_MCP_PACKAGE = 'add-mcp'; +const CLIX_MCP_SERVER_PACKAGE = '@clix-so/clix-mcp-server@latest'; +const MCP_SERVER_NAME = 'clix'; + +interface MCPCommandDependencies { + runHandoff?: typeof runCommandHandoff; + exitProcess?: (code: number) => void; +} + +export async function mcpCommand(dependencies: MCPCommandDependencies = {}): Promise { + const runHandoff = dependencies.runHandoff ?? runCommandHandoff; + const exitProcess = dependencies.exitProcess ?? setExitCode; + + console.log('Adding Clix MCP Server...'); + + let exitCode = 1; + try { + exitCode = await runHandoff({ + command: 'npx', + 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 AgentError(`Failed to launch add-mcp: ${message}`, 'add-mcp'); + } + + exitProcess(exitCode); +} 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/__tests__/preparation.test.ts b/src/commands/skill/__tests__/preparation.test.ts new file mode 100644 index 0000000..3d99f15 --- /dev/null +++ b/src/commands/skill/__tests__/preparation.test.ts @@ -0,0 +1,677 @@ +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(() => + Promise.resolve({ + 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: [] as unknown[], + projectPath: '/test', + }), + ), + getStatus: mock(() => + Promise.resolve({ + status: 'configured', + androidConfigured: true, + iosConfigured: true, + issueCount: 0, + errorCount: 0, + warningCount: 0, + }), + ), +}; + +const mockInternalApiClient = { + getProject: mock(() => + Promise.resolve({ + 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('test-project'), + }, + }, + }, + ], + }), + ), +}; + +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'), + 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, + 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; + getStatus = mockFirebaseService.getStatus; + }, +})); + +mock.module('@/lib/api', () => ({ + getInternalApiClient: () => mockInternalApiClient, +})); + +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, +})); + +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', + app_push: { + ios_config: { + fcm_sa_json_base64_encoded: createEncodedServiceAccount('test-project'), + }, + }, + }, + ], + }), + ); + + 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(); + + 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.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, + missingFiles: ['NotificationService.swift'], + })); + }); + + 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 ignore cached completion flags and fallback to file evidence', 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(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: [] }); + 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); + + 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: [] }); + 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); + + 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', () => { + 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' }; + const status = await checkFirebaseStatus('/test', projectType); + + 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 () => { + 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, '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); + expect(status.senderConfigProjectMatched).toBe(true); + }); + + 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, 'clix-project'); + + expect(status.projectId).toBe('my-project'); + }); + + 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, 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 () => { + 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, undefined, 'clix-project'); + + 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, 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); + }); + + 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 5bbd8bd..fad9f4e 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -1,121 +1,281 @@ -import type { AgentExecutor, AgentMessage } from '../../lib/executor'; +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 { 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'; +import { GenericSelector, type SelectorItem } from '../../ui/components/GenericSelector'; import { - executeSkill, - getAvailableSkills, - getAvailableSkillTypes, - getSkillInfo, - type SkillType, -} from '../../lib/skills'; -import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; -import { printFinalOutput } from '../../ui/utils/finalOutput'; + 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 { gatherPreparationContext, type PreparationContext } from './preparation'; interface SkillCommandOptions { action?: string; - platform?: 'ios' | 'android' | 'react-native' | 'flutter'; + 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'); - - const packageSkillsText = packageSkills - .map((s) => ` ${s.type.padEnd(25)} ${s.description} (interactive)`) - .join('\n'); +type CommandSkillType = 'install' | 'doctor'; - const exampleSkill = localSkills[0]?.type ?? 'install'; +function isCommandSkillType(action: string): action is CommandSkillType { + return action === 'install' || action === 'doctor'; +} +function generateHelpText(): string { return ` -Usage: clix [options] +Usage: clix [options] -Available skills (command-line mode): -${localSkillsText} - -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. + --start-task Development-only install task override Examples: - $ clix ${exampleSkill} + $ clix install $ clix doctor `; } -export async function skillCommand(options: SkillCommandOptions): Promise { - const { action, platform } = options; +async function runInstallPreparation( + projectPath: string, + startTaskId?: InstallTaskId, +): Promise { + return new Promise((resolve) => { + const { unmount } = safeRender( + ({ + success: false, + error: 'install_skill is disabled in command preparation mode.', + })} + onComplete={(context) => { + unmount(); + resolve(context); + }} + onCancel={() => { + unmount(); + resolve(null); + }} + />, + ); + }); +} - if (!action || !isValidSkillType(action)) { - console.log(generateHelpText()); - return; - } +interface DoctorActionItem extends SelectorItem { + action: 'diagnose' | 'exit'; +} - // Check if skill supports command-line execution - const skillInfo = getSkillInfo(action as SkillType); - if (!skillInfo) { - console.error(`Skill not found: ${action}`); - process.exit(1); - } +function DoctorStatusDisplay({ + context, + onAccept, + onCancel, +}: { + context: PreparationContext; + onAccept?: () => void; + onCancel: () => void; +}): React.ReactElement { + const layoutPolicy = getStatusLayoutPolicy(); + const statusRows = getStatusRows(context, layoutPolicy, {}, false); - 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); - } + const items: DoctorActionItem[] = [ + { id: 'diagnose', label: 'Run AI diagnosis', action: 'diagnose' }, + { id: 'exit', label: 'Exit', action: 'exit' }, + ]; - // 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 handleSelect = useCallback( + (item: DoctorActionItem) => { + if (item.action === 'diagnose') { + onAccept?.(); + } else { + onCancel(); + } + }, + [onAccept, onCancel], + ); - const skillType = action as SkillType; + return ( + + Clix SDK Doctor — Pre-check Status + + {statusRows.map((row) => ( + + ))} + - // Create execute function that wraps executeSkill - async function* executeCommand(executor: AgentExecutor): AsyncGenerator { - yield* executeSkill(skillType, executor, { - projectPath: process.cwd(), - platform, - oneShot: true, - }); - } + {!context.ready && ( + <> + + Missing setup: + {context.missing.map((item) => ( + + {' '}• {item} + + ))} + + + Run "clix install" to complete the required setup before + running doctor. + + + )} - // Wrapper to match AgentExecutionUI interface (ignores agent param) - async function* execute(executor: AgentExecutor, _agent: unknown): AsyncGenerator { - yield* executeCommand(executor); - } + {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(); - if (result) { - printFinalOutput(result); - } - resolve(); + resolve('cancelled'); }} />, ); + + if (!context.ready) { + setTimeout(() => { + unmount(); + resolve('cancelled'); + }, 0); + } }); } -function isValidSkillType(action: string): action is SkillType { - return getAvailableSkillTypes().includes(action); +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 { + const { action, startTask } = options; + + if (!action || !isCommandSkillType(action)) { + console.log(generateHelpText()); + return; + } + + const skillType: CommandSkillType = action; + 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(', ')}`); + setExitCode(1); + return; + } + + if (skillType !== 'install') { + console.error('--start-task is only supported with the install command.'); + 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.'); + setExitCode(1); + return; + } + + startTaskId = startTask as InstallTaskId; + } + + let preparationContext: PreparationContext | undefined; + if (skillType === 'install') { + const context = await runInstallPreparation(projectPath, startTaskId); + if (!context) { + return; + } + preparationContext = context; + } else if (skillType === 'doctor') { + const context = await gatherPreparationContext(projectPath); + if (!context) { + console.error('Project not linked. Run "clix login" first.'); + setExitCode(1); + return; + } + const result = await runDoctorPrecheck(context); + if (result === 'cancelled') { + setExitCode(context.ready ? 0 : 1); + return; + } + preparationContext = context; + } + + 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.', + ); + } + + const prompt = await getSkillPrompt(skillType, { + projectPath, + preparationContext, + }); + + console.log(`Launching ${agent.displayName}...`); + 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 launch ${agent.displayName}: ${message}`); + } + + setExitCode(exitCode); } diff --git a/src/commands/skill/preparation.ts b/src/commands/skill/preparation.ts new file mode 100644 index 0000000..4dc8999 --- /dev/null +++ b/src/commands/skill/preparation.ts @@ -0,0 +1,833 @@ +/** + * 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 * as path from 'node:path'; +import type { SenderConfig } from '@/lib/api'; +import { getInternalApiClient } from '@/lib/api'; +import { + getProjectConfigManager, + type ProjectConfig, + type ProjectType, + type SetupStatus, +} from '@/lib/config'; +import { + analyzeIosProject, + generateAppGroupId, + getExtensionBundleId, + getExtensionName, + getIosProjectDir, + getNotificationServiceExtensionStatus, + hasClixConfiguration, + hasClixPodInExtensionTarget, + hasExtensionTarget, + hasNotificationServiceExtension, + hasPodfile, + inspectNotificationServiceSwift, + readEntitlements, + verifyExtensionFiles, +} from '@/lib/ios'; +import { FirebaseService } from '@/lib/services/firebase/firebase-service'; +import { parseBase64ServiceAccountJson } from '@/lib/services/firebase/service-account-validator'; +import type { FirebaseDetectionResult, GoogleServicesJson } from '@/lib/services/firebase/types'; +import { detectAndroidPackageName, 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; + /** 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; + /** 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 { + configured: boolean; + 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. + */ +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 name extracted from Xcode project */ + appName?: string; + /** App Group ID if configured */ + appGroupId?: string; + /** Whether entitlements are configured */ + 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; +} + +/** + * 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. + */ +export interface PreparationContext { + /** Project root path */ + projectPath: string; + /** Loaded and migrated config */ + config: ProjectConfig; + /** Detected or loaded project type */ + projectType: ProjectType; + /** Firebase configuration status */ + firebase: FirebaseStatus; + /** iOS configuration status */ + ios: IosStatus; + /** APNS configuration status */ + apns: ApnsStatus; + /** 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?: ProjectConfig; + /** 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.load(); + + 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: ProjectConfig, + projectPath: string, +): Promise<{ config: ProjectConfig; 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: ProjectConfig = { + ...config, + projectType, + }; + await manager.save(updatedConfig); + + 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[] = []; + + 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 + * and whether it matches locally detected Firebase config project IDs. + * + * API/network errors are treated as not configured to avoid false positives. + */ +async function getAppPushSenderConfigStatus( + clixProjectId: string | undefined, + localFirebaseProjectIds: string[], +): Promise { + if (!clixProjectId) { + return { configured: false, projectMatched: false }; + } + + try { + const apiClient = getInternalApiClient(); + const project = await apiClient.getProject(clixProjectId); + 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 { configured: false, projectMatched: 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'; + + if (!needed) { + return { + configured: true, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + senderConfigProjectMatched: true, + needed: false, + }; + } + + // Always detect actual Firebase config files on disk + 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; + const credentialFilesConfigured = androidConfigured && iosConfigured; + 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 (localProjectIds.length > 0) { + projectId = localProjectIds[0]; + } else if (setup?.firebase?.projectId) { + 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, + iosConfigured, + senderConfigConfigured, + senderConfigProjectMatched, + projectId, + detectedIosBundleId, + detectedAndroidPackageName, + detectedAndroidPackageNames, + needed: true, + }; +} + +interface IosFileStatus { + bundleId?: string; + teamId?: string; + appName?: string; + appGroupId?: string; + entitlementsConfigured: boolean; + nseConfigured: boolean; + nseDetails?: NseVerificationStatus; +} + +/** + * 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); + const extensionName = getExtensionName(project.appName); + let hasEntitlements = false; + let detectedAppGroupId: string | undefined; + + 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); + 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 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, + appName: project.appName, + appGroupId: detectedAppGroupId ?? setup?.appGroupId, + entitlementsConfigured: hasEntitlements, + nseConfigured: hasNse, + nseDetails: { + extensionName, + extensionBundleId, + filesComplete, + xcodeTargetConfigured, + buildSettingsConfigured, + podDependencyConfigured, + notificationServiceConfigured, + }, + }; +} + +/** + * 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, + }; + } + 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, + }; + } + + if (setup?.apns) { + return { + needed: true, + keyId: setup.apns.keyId, + teamId: setup.apns.teamId, + registeredWithFirebase: setup.apns.registeredWithFirebase, + }; + } + + return { + needed: true, + registeredWithFirebase: false, + }; +} + +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. + * + * @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: configWithProjectType, projectType } = await ensureProjectType( + config, + projectPath, + ); + const updatedConfig = await ensureProjectPublicKey(configWithProjectType, projectPath); + + // Step 3: Check Firebase status + 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: Check APNS status + const apns = await checkApnsStatus(projectPath, projectType, updatedConfig.setup); + + // 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; + } + + // 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 { + projectPath, + config: updatedConfig, + projectType, + firebase, + ios, + apns, + 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, + apns?: ApnsStatus, +): 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, + }; + } + + 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/commands/skills.ts b/src/commands/skills.ts new file mode 100644 index 0000000..e3a4289 --- /dev/null +++ b/src/commands/skills.ts @@ -0,0 +1,32 @@ +import { AgentError } from '../lib/errors'; +import { setExitCode } from '../lib/exit'; +import { runCommandHandoff } from '../lib/services/agent-handoff'; + +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 ?? setExitCode; + + 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 AgentError(`Failed to launch Skills CLI: ${message}`, 'Skills CLI'); + } + + exitProcess(exitCode); +} 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/__tests__/embedded-skills.test.ts b/src/lib/__tests__/embedded-skills.test.ts index 3ae5a4a..b0f4e99 100644 --- a/src/lib/__tests__/embedded-skills.test.ts +++ b/src/lib/__tests__/embedded-skills.test.ts @@ -1,304 +1,27 @@ -/** - * 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, EMBEDDED_SKILLS, getEmbeddedSkill, getEmbeddedSkillFolders, - getSkillMetadataByCommand, - getSkillMetadataByFolder, 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('Integration Workflow'); }); - 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'); - }); - - 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); + expect(folders).toContain('local-install'); + expect(folders).toContain('local-doctor'); }); }); 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 28271b8..15f0521 100644 --- a/src/lib/__tests__/skills.test.ts +++ b/src/lib/__tests__/skills.test.ts @@ -1,467 +1,238 @@ -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 { PreparationContext } from '@/commands/skill/preparation'; +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 + 1 local (doctor) - 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'); - - 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'); - - expect(prompt).toContain('package.json'); - expect(prompt).toContain('pubspec.yaml'); - expect(prompt).toContain('xcodeproj'); - expect(prompt).toContain('build.gradle'); - }); + test('builds doctor prompt with project path', async () => { + const prompt = await getSkillPrompt('doctor', { projectPath: '/tmp/project' }); + + 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', + }); + + expect(prompt).toContain('Project path: /tmp/project'); + 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'); + }); + + 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: [], + }; - test('should include SDK installation check instructions', async () => { - const prompt = await getSkillPrompt('doctor'); + const prompt = await getSkillPrompt('install', { + projectPath: '/tmp/project', + preparationContext, + }); + + 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: [], + }; - expect(prompt).toContain('ClixSDK'); - expect(prompt).toContain('@clix-so/react-native-sdk'); - expect(prompt).toContain('clix_flutter_sdk'); + const prompt = await getSkillPrompt('doctor', { + projectPath: '/tmp/project', + preparationContext, }); - test('should use cwd when projectPath not provided', async () => { - const prompt = await getSkillPrompt('doctor'); - expect(prompt).toContain(`Project path: ${process.cwd()}`); - }); + 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'); }); - 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('builds doctor prompt without context when not provided', async () => { + const prompt = await getSkillPrompt('doctor', { projectPath: '/tmp/project' }); - 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('personalization skill should load from package', async () => { - const prompt = await getSkillPrompt('personalization', { - projectPath: '/test/project', - }); - - expect(prompt).toContain('Project path: /test/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('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'); - }); -}); - -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(); - } - }); -}); - -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('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); - }); + expect(messages.map((m) => m.content)).toEqual(['Part 1', 'Part 2', '']); }); - 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('passes executor options through', async () => { + let capturedOptions: ExecuteOptions | undefined; + let capturedPrompt = ''; - test('event-tracking skill should contain trackEvent instructions', () => { - const skill = getEmbeddedSkill('event-tracking'); - expect(skill).toContain('clix-event-tracking'); - expect(skill).toContain('trackEvent'); + const executor = createMockExecutor({ + execute: mock(async function* ( + prompt: string, + options?: ExecuteOptions, + ): AsyncGenerator { + capturedPrompt = prompt; + capturedOptions = options; + yield { type: 'complete', content: '' }; + }), }); - 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..22527e1 100644 --- a/src/lib/__tests__/test-utils/fixtures.ts +++ b/src/lib/__tests__/test-utils/fixtures.ts @@ -1,55 +1,7 @@ -import type { ConversationMessage } from '../../executor'; - /** * 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 */ @@ -103,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/__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/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..61cf7a0 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -24,11 +24,36 @@ 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; +} + +/** + * 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/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); } /** diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index 1dd842e..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)); } } @@ -362,16 +364,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/commands/__tests__/registry.test.ts b/src/lib/commands/__tests__/registry.test.ts deleted file mode 100644 index 46cd257..0000000 --- a/src/lib/commands/__tests__/registry.test.ts +++ /dev/null @@ -1,265 +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'); - }); - - 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', () => { - 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/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/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/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/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 0f50d11..0000000 --- a/src/lib/commands/registry.ts +++ /dev/null @@ -1,128 +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 { 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'; -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, - firebaseCommand, - iosSetupCommand, - 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 da79f0f..0000000 --- a/src/lib/commands/skills.ts +++ /dev/null @@ -1,116 +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 installation with file modifications', - 'install', - ), - ); - commands.push(createLocalSkillCommand('doctor', 'Check SDK integration status', 'doctor')); - // NOTE: ios-setup is now a LocalJSXCommand in registry.ts, not a skill - - 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/config/index.ts b/src/lib/config/index.ts index b72f115..24840b2 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -8,7 +8,14 @@ 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, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, type ProjectConfig, @@ -23,6 +30,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..0ccf8b7 100644 --- a/src/lib/config/project-config-manager.ts +++ b/src/lib/config/project-config-manager.ts @@ -1,29 +1,21 @@ 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, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, type ProjectConfig, + type SetupStatus, safeValidateProjectConfig, } from './project-config-schema'; /** * Comment header for the project config file. */ -const CONFIG_HEADER = `// Clix CLI 프로젝트 설정 -// 자동 생성됨 - 수동 수정 시 덮어쓰기될 수 있음 -`; - -/** - * 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'; +const CONFIG_HEADER = ''; /** * ProjectConfigManager handles loading and saving project-local configuration. @@ -93,12 +85,14 @@ export class ProjectConfigManager { await stat(this.configDirPath); } catch { await mkdir(this.configDirPath, { recursive: true, mode: 0o755 }); + await ensureClixGitignore(this.projectPath); } } /** * 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 +120,18 @@ export class ProjectConfigManager { ); } - this.cachedConfig = validatedConfig; - return validatedConfig; + // 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 || migratedConfigChanged) { + 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 +156,33 @@ export class ProjectConfigManager { } } + /** + * 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.load(); + if (!config) { + throw new ConfigError( + 'Project config not found. Run "clix login" first.', + ERROR_CODES.PROJECT_CONFIG_NOT_FOUND, + this.configFilePath, + ); + } + + const updatedConfig: ProjectConfig = { + ...config, + setup: { + ...config.setup, + ...updates, + }, + }; + + await this.save(updatedConfig); + } + /** * Save project configuration to disk. * Creates the .clix directory if it doesn't exist. @@ -182,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/config/project-config-schema.ts b/src/lib/config/project-config-schema.ts index cec3823..60774a3 100644 --- a/src/lib/config/project-config-schema.ts +++ b/src/lib/config/project-config-schema.ts @@ -47,13 +47,81 @@ 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(), +}); + +/** + * 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(), }); /** @@ -62,7 +130,7 @@ export const ProjectInfoSchema = z.object({ */ export const ProjectConfigSchema = z.object({ /** Configuration schema version */ - version: z.literal(1), + version: z.number(), /** Logged-in member information */ member: ProjectMemberSchema, /** Selected organization */ @@ -73,6 +141,8 @@ export const ProjectConfigSchema = z.object({ projectType: ProjectTypeSchema.optional(), /** ISO timestamp when this config was created/linked */ linkedAt: z.string().datetime(), + /** Setup status tracking for install preparation */ + setup: SetupStatusSchema.optional(), }); /** @@ -81,6 +151,11 @@ 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; +export type SetupStatus = z.infer; export type ProjectConfig = z.infer; /** @@ -120,3 +195,32 @@ export function safeValidateProjectConfig(data: unknown): ProjectConfig | null { const result = ProjectConfigSchema.safeParse(data); return result.success ? result.data : null; } + +/** + * Ensure config is at the latest version. + * Migrates older versions if necessary. + * + * @param config - Config of any supported version + * @returns Config at latest version + */ +export function ensureLatestVersion(config: ProjectConfig): ProjectConfig { + 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, + version: CURRENT_PROJECT_CONFIG_VERSION, + 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 15d8eb6..737f322 100644 --- a/src/lib/constants/system-prompt.ts +++ b/src/lib/constants/system-prompt.ts @@ -64,16 +64,10 @@ Platform-specific configs to verify: - Remind user to replace with actual credentials - Verify placeholder replacement before completion -## Available Slash Commands -- /install - Autonomous SDK installation with automatic file modifications -- /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 -- /install-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/debug/logger.ts b/src/lib/debug/logger.ts index db0a46a..81e6bb9 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,51 @@ 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 { + const safeStringify = (value: unknown): string => { + try { + return JSON.stringify(value); + } catch { + return '[unserializable]'; + } + }; + + // 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 serialized = data !== undefined ? safeStringify(data) : ''; + const line = `${timestamp} ${this.namespace} ${message}${data !== undefined ? ` ${serialized}` : ''}\n`; + appendFileSync(logFile, line); + return; // Success, exit loop + } catch { + // Try next location + } + } + // All locations failed, log to stderr as last resort + const fallback = data !== undefined ? safeStringify(data) : ''; + console.error(`[${this.namespace}] ${message}`, fallback); + } } /** @@ -197,3 +247,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/embedded-skills.ts b/src/lib/embedded-skills.ts index 8d3286f..a2314b1 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -1,1495 +1,215 @@ /** - * 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. - */ -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) + 'local-install': `# Clix SDK Autonomous Install -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 +You are an autonomous AI agent that handles \`clix install\` runtime integration work. -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. +## Scope -## What the official docs guarantee (high-signal) +This skill runs after \`clix install\` preparation has validated required setup. -- **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 Installation - -You are an autonomous AI agent that installs and integrates the Clix mobile push notification SDK. +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 ## 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. Do not ask 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 - -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/ +## Integration Workflow -### 5. Run Post-Installation Commands +### 1. Validate context and project type -Execute necessary commands: -- \`npm install\` or \`yarn install\` after package.json changes -- \`cd ios && pod install\` for iOS dependencies -- \`flutter pub get\` for Flutter +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 +- \`package.json\` + \`react-native\` dependency + \`android/\` and \`ios/\` -> React Native +- \`package.json\` + \`expo\` dependency OR \`app.json\` with \`expo\` -> Expo +- \`pubspec.yaml\` -> Flutter -### 6. Verify Firebase Configuration +If context and file evidence conflict, use file evidence and report the mismatch. -For push notifications to work, Firebase must be properly configured: +### 2. Add Clix SDK dependency -**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 +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\` -**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 +### 3. Integrate SDK initialization -**Validation:** -- Check if files exist in correct locations -- Verify JSON/plist structure is valid -- Confirm project IDs match between platforms (for cross-platform apps) +Add initialization code in the real app startup path. -Use \`/firebase\` command in interactive mode to check and configure Firebase credentials. +Use provided values from preparation context when available: +- Clix Project ID +- Clix Project Public API Key -## Automation Rules +If values are not available, use placeholders: +- \`YOUR_CLIX_PROJECT_ID\` +- \`YOUR_CLIX_PUBLIC_API_KEY\` -✅ **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 +### 4. Run post-change dependency commands -❌ **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) +Run only necessary dependency-resolution commands: +- \`npm install\` / \`yarn install\` / \`pnpm install\` / \`bun install\` +- \`pod install\` (if Podfile changed) +- \`flutter pub get\` -## IDE-Only Manual Steps +### 5. Verify integration and fix failures -Only these require user action: -- Xcode: Adding capabilities, configuring entitlements -- Android Studio: Firebase setup UI, capability configuration -- Building and running the project +Verify: +- dependency declarations were added correctly +- initialization exists in startup path +- imports and references resolve +- no duplicate initialization -For these, provide brief instructions but don't wait for confirmation. +Run build commands for verification after integration changes. -### iOS Notification Service Extension (Recommended) +If verification build fails: +- identify root cause quickly +- apply minimal targeted fixes +- retry automatically +- stop only on hard blockers (credentials/permissions/external service unavailable) -For rich push notifications (images, buttons), create a Notification Service Extension: +## Rules -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 +Do: +- apply concrete file changes +- keep edits minimal and project-specific +- preserve existing code style - class NotificationService: ClixNotificationServiceExtension { - override init() { - super.init() - register(projectId: "YOUR_PROJECT_ID") - } +Do not: +- redo preparation tasks +- ask user to manually apply edits +- add speculative abstractions - override func didReceive(_ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - super.didReceive(request, withContentHandler: contentHandler) - } +## Output Format - 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 +At completion, report: +- modified files +- commands executed +- placeholders still requiring real values +- hard blockers (if any) -For detailed setup, run \`clix ios-setup\` or \`/ios-setup\` in interactive mode. +At the very end, always include a final status block in this exact shape: -## Output Format - -After completion, report: -✓ Files created/modified (with paths) -✓ Commands executed -✓ Placeholders that need replacement -✓ Any IDE-only steps required +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 You are analyzing a mobile project for Clix SDK integration status. -## Task +## Pre-verified Context -Analyze the project and output a diagnostic JSON report: +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. -\`\`\`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"] -} -\`\`\` +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) ## Analysis Checklist -### 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) - ### 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: Check code for push notification permission request (UNUserNotificationCenter requestAuthorization) +- Android: Check AndroidManifest.xml for FCM service declaration -**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 +### 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 -Output the JSON diagnostic, then provide a brief summary with actionable recommendations. +### Issues Found +List each issue with severity: +- [ERROR] Description — file path — recommended fix +- [WARNING] Description — file path — recommended fix +- [INFO] Description -Use \`/firebase\` command to interactively check and configure Firebase credentials. +If no issues found, state "No issues detected." + +### 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: + +Final Result: HEALTHY | ACTION_NEEDED | FAILED +- Summary: one concise sentence +- Critical issues: number +- Warnings: number +- Recommended next action: single highest-priority action + +Use 'clix install' to set up SDK integration if not yet installed. `, }; -/** - * Embedded skill metadata. - */ -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, - }, -]; - -/** - * 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); -} - -/** - * 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); -} diff --git a/src/lib/executor.ts b/src/lib/executor.ts index f435d91..8c60a2a 100644 --- a/src/lib/executor.ts +++ b/src/lib/executor.ts @@ -2,53 +2,27 @@ import type { AgentInfo } from './agents'; 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'; + 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; } -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 fa51755..7f8a894 100644 --- a/src/lib/executors/__tests__/claude-executor.test.ts +++ b/src/lib/executors/__tests__/claude-executor.test.ts @@ -36,34 +36,22 @@ describe('ClaudeExecutor', () => { expect(mockCommandExists).toHaveBeenCalledWith('claude'); }); }); -}); - -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); + 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'); - // Verify session ID is set - expect(executor.getSessionId()).toBe(testSessionId); - - // Create an already-aborted AbortController - const abortController = new AbortController(); - abortController.abort(); + expect(args).toContain('--allow-dangerously-skip-permissions'); + expect(args).toContain('--permission-mode'); + expect(args).toContain('bypassPermissions'); + }); - // Execute with aborted signal - const messages: unknown[] = []; - for await (const message of executor.execute('test prompt', { - signal: abortController.signal, - })) { - messages.push(message); - } + test('should always include no-session-persistence', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing protected method + const args = (executor as any).buildArgs('test prompt'); - // Session ID should be preserved after abort (not reset to null) - expect(executor.getSessionId()).toBe(testSessionId); + expect(args).toContain('--no-session-persistence'); }); }); }); @@ -163,3 +151,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..8a62448 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'); + + expect(args).toContain('--dangerously-bypass-approvals-and-sandbox'); + expect(args).not.toContain('--sandbox'); + }); + }); }); describe('CodexExecutor message mapping', () => { 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/__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..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', @@ -195,3 +182,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..18b9ecf 100644 --- a/src/lib/executors/__tests__/opencode-executor.test.ts +++ b/src/lib/executors/__tests__/opencode-executor.test.ts @@ -36,21 +36,46 @@ describe('OpenCodeExecutor', () => { expect(mockCommandExists).toHaveBeenCalledWith('opencode'); }); }); -}); -describe('OpenCode CLI message format', () => { - test('session event structure', () => { - const msg: OpenCodeCLIMessage = { - type: 'session', - timestamp: 1768445719603, - sessionID: 'ses_4406be0e0ffek7fDmqJpninhMz', - model: 'gpt-4', - }; + 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; - expect(msg.type).toBe('session'); - expect(msg.sessionID).toBe('ses_4406be0e0ffek7fDmqJpninhMz'); + 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', () => { test('error event structure (confirmed)', () => { const msg: OpenCodeCLIMessage = { type: 'error', 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..2df4baf 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, @@ -25,6 +14,7 @@ import { spawnCLIProcess, waitForProcessExit, } from './cli-process-manager'; +import { extractCumulativeDelta } from './stream-delta'; /** * Stream parser type - determines how to parse CLI output @@ -55,24 +45,17 @@ 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; + protected lastTextContent = ''; private readonly command: string; private readonly notFoundMessage: string; @@ -87,7 +70,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 +96,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. @@ -148,16 +113,30 @@ export abstract class BaseExecutor implements AgentExecutor { return debug === '1' || debug === 'true' || debug === '*'; } - protected getPreparedPrompt(prompt: string, options?: ExecuteOptions): string { - const shouldInject = options?.oneShot || !this.systemPromptInjected; - if (!shouldInject) { - return prompt; - } + /** + * 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; + } - this.systemPromptInjected = true; - return `${CLIX_SYSTEM_PROMPT} + protected getPreparedPrompt(prompt: string): string { + return `${CLIX_SYSTEM_PROMPT}\n\n${prompt}`; + } -${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 { @@ -168,22 +147,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) { @@ -200,7 +169,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)})`; } @@ -215,17 +183,27 @@ ${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())) { 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 { @@ -247,6 +225,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 }); @@ -257,7 +236,6 @@ ${prompt}`; count: 0, }; - // Start waiting for process exit (don't await yet) const exitPromise = waitForProcessExit(proc, stderr); try { @@ -282,25 +260,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) { @@ -308,7 +272,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', { @@ -316,10 +279,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; } } @@ -329,65 +289,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 a46292c..bcca8e7 100644 --- a/src/lib/executors/claude-executor.ts +++ b/src/lib/executors/claude-executor.ts @@ -7,9 +7,6 @@ import { BaseExecutor, type StreamContext, type StreamParserType } from './base- import type { CLIContentBlock, ClaudeCLIMessage } from './types'; export class ClaudeExecutor extends BaseExecutor { - // Track accumulated text to compute deltas - private lastTextContent = ''; - constructor() { super({ name: 'claude', @@ -29,32 +26,18 @@ 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: 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'); - - // 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); - } - - return args; - } + // 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'); - protected override extractSessionId(data: unknown): string | null { - const msg = data as ClaudeCLIMessage; - return msg.session_id ?? null; - } + // Always disable session persistence for command-only execution. + args.push('--no-session-persistence'); - protected override onCompactionComplete(): void { - this.sessionId = null; + return args; } protected processStreamData( @@ -105,24 +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 = textContent.startsWith(this.lastTextContent) - ? textContent.slice(this.lastTextContent.length) - : textContent; - - this.lastTextContent = textContent; - - if (delta) { - return { - type: 'text', - content: delta, - metadata: msg as unknown as Record, - }; - } - - return null; - } - private extractToolUses(msg: ClaudeCLIMessage): AgentMessage[] { const toolUses = msg.message?.content?.filter( @@ -172,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/cli-process-manager.ts b/src/lib/executors/cli-process-manager.ts index c9432ca..634437a 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..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); @@ -89,12 +86,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, @@ -104,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 2144f50..a074413 100644 --- a/src/lib/executors/cursor-executor.ts +++ b/src/lib/executors/cursor-executor.ts @@ -16,6 +16,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'; } @@ -26,11 +31,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'); @@ -45,15 +45,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 mapAssistantMessage(msg: CursorCLIMessage): AgentMessage | AgentMessage[] | null { if (!msg.message?.content) return null; @@ -69,11 +60,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); + } } // 도구 호출 추출 @@ -128,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 0d0402d..d31cce7 100644 --- a/src/lib/executors/gemini-executor.ts +++ b/src/lib/executors/gemini-executor.ts @@ -4,9 +4,9 @@ */ import type { AgentMessage, ExecuteOptions } from '../executor'; import { BaseExecutor, type StreamContext, type StreamParserType } from './base-executor'; +import { extractCumulativeDelta } from './stream-delta'; import type { GeminiCLIErrorEvent, - GeminiCLIInitEvent, GeminiCLIMessage, GeminiCLIMessageEvent, GeminiCLIResultEvent, @@ -28,7 +28,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']; @@ -40,11 +40,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'); @@ -52,16 +47,9 @@ 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); } protected processStreamData( @@ -76,9 +64,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..e911a32 100644 --- a/src/lib/executors/opencode-executor.ts +++ b/src/lib/executors/opencode-executor.ts @@ -8,12 +8,22 @@ import type { OpenCodeCLIErrorEvent, OpenCodeCLIMessage, OpenCodeCLIMessageEvent, - OpenCodeCLISessionEvent, OpenCodeCLITextEvent, OpenCodeCLIToolCallEvent, 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,17 +38,12 @@ export class OpenCodeExecutor extends BaseExecutor { return 'jsonl'; } - protected buildArgs(prompt: string, options?: ExecuteOptions): string[] { - const args = ['run', '--format', 'json']; + protected override getSpawnEnv(_options?: ExecuteOptions): NodeJS.ProcessEnv | undefined { + return createDefaultOpenCodeEnv(); + } - // 세션 관리 - if (!options?.oneShot && this.sessionId) { - // 특정 세션 재개 - args.push('-s', this.sessionId); - } else if (!options?.oneShot) { - // 마지막 세션 계속 - args.push('-c'); - } + protected buildArgs(prompt: string, _options?: ExecuteOptions): string[] { + const args = ['run', '--format', 'json']; // 디버그 모드 if (this.isDebugMode()) { @@ -51,27 +56,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, @@ -86,6 +70,7 @@ export class OpenCodeExecutor extends BaseExecutor { return { type: 'text', content: messageEvent.content, + streamMode: 'append', metadata: { role: messageEvent.role, timestamp: messageEvent.timestamp, @@ -101,6 +86,7 @@ export class OpenCodeExecutor extends BaseExecutor { return { type: 'text', content: text, + streamMode: 'append', metadata: { timestamp: textEvent.timestamp, }, @@ -183,7 +169,6 @@ export class OpenCodeExecutor extends BaseExecutor { } // 완료 메시지는 base class에서 처리 - // 세션 시작 메시지는 sessionID만 추출 return null; } 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/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/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/__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/apple-auth.ts b/src/lib/ios/apple-auth.ts index cfe3e52..27fee73 100644 --- a/src/lib/ios/apple-auth.ts +++ b/src/lib/ios/apple-auth.ts @@ -115,12 +115,24 @@ export async function promptAppleIdAsync( ): Promise { const lastAppleId = await getCachedUsernameAsync(); + if (lastAppleId) { + console.log(`› Cached Apple ID detected: ${lastAppleId}`); + } console.log('› Log in to your Apple Developer account to continue'); - let username = await promptFn('Apple ID:', lastAppleId ?? undefined); - - // Remove any unprintable control characters (ASCII 0-31) - username = removeControlCharacters(username); + let username = ''; + while (!username.trim()) { + 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.', + ); + } + } if (username && username !== lastAppleId) { await cacheUsernameAsync(username); @@ -145,7 +157,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/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/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/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..ff5c54b 100644 --- a/src/lib/ios/pbxproj-modifier.ts +++ b/src/lib/ios/pbxproj-modifier.ts @@ -9,8 +9,355 @@ 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[]; + dependencyAdded?: boolean; +} { + 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'); + } + + 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', + ); + } + } + + return { target: { uuid: targetId }, warnings, dependencyAdded }; +} + +function createNotificationServiceTarget( + project: XcodeProjectWithStrictSafeTargetApis & { + addTarget: ( + name: string, + targetType: string, + subfolder: string, + bundleId: string, + ) => { uuid: string } | null; + }, + options: Pick, +): { + target: { uuid: string } | null; + warnings: string[]; + dependencyAdded?: boolean; +} { + 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, + ], + dependencyAdded: fallbackResult.dependencyAdded, + }; + } +} /** * Options for pbxproj modification operations. @@ -44,6 +391,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 +423,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 +465,535 @@ 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; + } +} + +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. */ @@ -135,40 +1018,52 @@ 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; - } - - // 3. Add NSE target - const target = project.addTarget( - options.extensionName, - NSE_PRODUCT_TYPE, - options.extensionName, - options.extensionBundleId, - ); + // 2. Ensure target exists (create only if missing) + let targetUuid = findTargetUuidByName(project, options.extensionName); + let targetDependencyAlreadyAdded = false; + if (!targetUuid) { + const { target, warnings, dependencyAdded } = 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); + targetDependencyAlreadyAdded = dependencyAdded === true; - 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 +1080,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 +1088,19 @@ 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', - ); - } - } else { - result.warnings.push('Could not find main app target, extension may need manual embedding'); + // Skip only if dependency was already added during target creation + if (!targetDependencyAlreadyAdded) { + addTargetDependencyToMainApp(project, targetUuid, result.warnings); } // 7. Write changes to project file @@ -240,6 +1125,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..523471b 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]*?)\\n\\s*end`, + '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( + /\n(\s*)end$/i, + `\n$1 pod '${options.clixPodSpec || 'Clix'}'\n$1end`, + ); + 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/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/__tests__/agent-handoff.test.ts b/src/lib/services/__tests__/agent-handoff.test.ts new file mode 100644 index 0000000..431f309 --- /dev/null +++ b/src/lib/services/__tests__/agent-handoff.test.ts @@ -0,0 +1,287 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { EventEmitter } from 'node:events'; +import type { AgentInfo } from '@/lib/agents'; +import type { + AgentHandoffInvocation, + CommandHandoffInvocation, + HandoffProcess, + HandoffSpawner, + HandoffSpawnOptions, +} 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; + 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 = 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', + ); + }); +}); + +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__/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__/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/__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/__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-handoff.ts b/src/lib/services/agent-handoff.ts new file mode 100644 index 0000000..1413bf6 --- /dev/null +++ b/src/lib/services/agent-handoff.ts @@ -0,0 +1,225 @@ +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; +}; + +export interface AgentHandoffInvocation { + agent: AgentInfo; + command: string; + args: string[]; + workingDirectory: string; + env?: NodeJS.ProcessEnv; +} + +export interface CommandHandoffInvocation { + command: string; + args: string[]; + workingDirectory: string; + env?: NodeJS.ProcessEnv; + displayName?: string; +} + +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; + +interface HandoffExecutionInvocation { + command: string; + args: string[]; + workingDirectory: string; + env?: NodeJS.ProcessEnv; + displayName: string; +} + +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 { + 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) { + 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) => { + let settled = false; + let child: HandoffProcess; + + const rejectWithSpawnError = (error: Error): void => { + if (settled) { + return; + } + + settled = true; + reject(new Error(`Failed to start ${invocation.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/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/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/api/firebase-api.ts b/src/lib/services/firebase/api/firebase-api.ts index 6e6a51d..d7a7f0a 100644 --- a/src/lib/services/firebase/api/firebase-api.ts +++ b/src/lib/services/firebase/api/firebase-api.ts @@ -1,261 +1,268 @@ /** - * Firebase Management REST API client. + * Firebase Management API client using @googleapis/firebase. * * @module services/firebase/api/firebase-api */ +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, - AppConfigResponse, CreateAndroidAppRequest, CreateIosAppRequest, FirebaseProject, IosApp, - ListAndroidAppsResponse, - ListIosAppsResponse, - ListProjectsResponse, - Operation, + UpdateIosAppRequest, } from './types'; -const BASE_URL = 'https://firebase.googleapis.com/v1beta1'; +type FirebaseApi = firebase_v1beta1.Firebase; /** - * Firebase Management API client. - * - * Uses the Firebase Management REST API to list projects, apps, and download configs. + * Log API errors to debug.log for troubleshooting. */ -export class FirebaseApiClient { - private getAccessToken: () => Promise; +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()); +} - /** - * Create a new Firebase API client. - * - * @param getAccessToken - Function to get a valid access token - */ - constructor(getAccessToken: () => Promise) { - this.getAccessToken = getAccessToken; +export interface ApiClientCredentials { + clientId: string; + clientSecret: string; +} + +export class FirebaseApiClient { + private fb: FirebaseApi; + private auth: OAuth2Client; + private getAccessTokenFn: () => Promise; + + constructor(getAccessToken: () => Promise, credentials?: ApiClientCredentials) { + this.getAccessTokenFn = 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.auth }); } /** - * Make an authenticated GET request to the Firebase API. + * Update OAuth2Client credentials with current access token. + * Must be called before each API request. */ - 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', - }, + private async updateCredentials(): Promise { + const token = await this.getAccessTokenFn(); + this.auth.setCredentials({ + access_token: token, + token_type: 'Bearer', }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Firebase API error (${response.status}): ${error}`); - } - - return response.json() as Promise; } - /** - * 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), - }); + async listProjects(): Promise { + 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 === 'DELETED' ? 'DELETED' : 'ACTIVE', + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Firebase API error (${response.status}): ${error}`); + return projects; + } catch (error) { + logApiError('listProjects', error); + throw error; } - - return response.json() as Promise; } - /** - * 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(/^\/+/, '/')); - - 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'); + async listAndroidApps(projectId: string): Promise { + try { + await this.updateCredentials(); + 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, + }); } - return operation.response; - } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - // Wait before polling again - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + return apps; + } catch (error) { + logApiError('listAndroidApps', error); + throw error; } - - throw new Error(`Operation timed out after ${maxWaitMs}ms`); } - /** - * 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[] = []; - 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); + async listIosApps(projectId: string): Promise { + try { + await this.updateCredentials(); + 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, + teamId: a.teamId ?? undefined, + appStoreId: a.appStoreId ?? undefined, + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - const extracted = extractor(response); - if (extracted) { - items.push(...extracted); - } - pageToken = response.nextPageToken; - } while (pageToken); + return apps; + } catch (error) { + logApiError('listIosApps', error); + throw error; + } + } - return items; + async getAndroidConfig(projectId: string, appId: string): Promise { + await this.updateCredentials(); + const res = await this.fb.projects.androidApps.getConfig({ + name: `projects/${projectId}/androidApps/${appId}/config`, + }); + return Buffer.from(res.data.configFileContents ?? '', 'base64').toString('utf-8'); } - /** - * 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'); + async getIosConfig(projectId: string, appId: string): Promise { + await this.updateCredentials(); + 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 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); + async createAndroidApp(projectId: string, request: CreateAndroidAppRequest): Promise { + await this.updateCredentials(); + const res = await this.fb.projects.androidApps.create({ + parent: `projects/${projectId}`, + requestBody: request, + }); - // If operation is already done, return the result - if (operation.done && operation.response) { - return operation.response; + if (!res.data.name) { + throw new Error('Failed to create Android app: no operation name returned'); } - - // 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, - ); + 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, + }; } - /** - * 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, - ); - } + async createIosApp(projectId: string, request: CreateIosAppRequest): Promise { + await this.updateCredentials(); + const res = await this.fb.projects.iosApps.create({ + parent: `projects/${projectId}`, + requestBody: request, + }); - /** - * 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, - ); + if (!res.data.name) { + throw new Error('Failed to create iOS app: no operation name returned'); + } + 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, + teamId: app.teamId ?? undefined, + appStoreId: app.appStoreId ?? undefined, + }; } - /** - * 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`); + 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, + }; } - /** - * 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`); - } + private async waitForOperation(operationName: string, maxWaitMs = 60000): Promise { + const startTime = Date.now(); - /** - * 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); - } + 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) { + 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)); + } - /** - * 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); + throw new Error(`Operation timed out after ${maxWaitMs}ms`); } } diff --git a/src/lib/services/firebase/api/index.ts b/src/lib/services/firebase/api/index.ts index 70386db..8257c52 100644 --- a/src/lib/services/firebase/api/index.ts +++ b/src/lib/services/firebase/api/index.ts @@ -4,17 +4,13 @@ * @module services/firebase/api */ -export { FirebaseApiClient } from './firebase-api'; +export { type ApiClientCredentials, FirebaseApiClient } from './firebase-api'; export type { AndroidApp, - AppConfigResponse, CreateAndroidAppRequest, CreateIosAppRequest, - FirebaseApiError, FirebaseProject, IosApp, - ListAndroidAppsResponse, - ListIosAppsResponse, - ListProjectsResponse, - Operation, + ServiceAccountJson, + UpdateIosAppRequest, } from './types'; diff --git a/src/lib/services/firebase/api/types.ts b/src/lib/services/firebase/api/types.ts index 1bc8fbd..dd81237 100644 --- a/src/lib/services/firebase/api/types.ts +++ b/src/lib/services/firebase/api/types.ts @@ -4,200 +4,60 @@ * @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; + teamId?: string; + appStoreId?: 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; + teamId?: string; + appStoreId?: 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; +export interface UpdateIosAppRequest { + teamId?: string; + appStoreId?: string; + displayName?: string; +} - /** - * Error details if operation failed. - */ - error?: { - code: number; - message: string; - details?: unknown[]; - }; +export interface ServiceAccountJson { + type: 'service_account'; + project_id: string; + private_key_id: string; + private_key: string; + client_email: string; + client_id: string; + auth_uri: string; + token_uri: string; + auth_provider_x509_cert_url: string; + client_x509_cert_url: string; + universe_domain?: string; } diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index c690145..eb51576 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -15,10 +15,16 @@ import type { CreateIosAppRequest, FirebaseProject, IosApp, + ServiceAccountJson, + UpdateIosAppRequest, } from './api'; -import { FirebaseApiClient } from './api'; +import { type ApiClientCredentials, FirebaseApiClient } from './api'; import { getExpectedPaths } from './detector'; -import { GoogleAuthClient } from './oauth'; +import { GoogleAuthClient, getOAuthCredentials } from './oauth'; +import { + parseServiceAccountJson, + type ServiceAccountValidationResult, +} from './service-account-validator'; import { type Platform, platformNeedsAndroid, platformNeedsIos } from './types'; /** @@ -62,6 +68,7 @@ export interface DownloadResult { export class FirebaseDownloader { private authClient: GoogleAuthClient; private apiClient: FirebaseApiClient | null = null; + private credentials: ApiClientCredentials | null = null; constructor() { this.authClient = new GoogleAuthClient(); @@ -90,19 +97,48 @@ 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) { - 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; } + /** + * Cancel in-flight Firebase OAuth authentication. + */ + cancelAuthentication(reason = 'Firebase authentication cancelled'): void { + this.authClient.cancelAuthentication(reason); + } + /** * 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; } @@ -114,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.listProjects(); } @@ -125,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.listAndroidApps(projectId); } @@ -136,7 +172,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); } @@ -165,7 +201,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); } @@ -180,10 +216,30 @@ 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); } + /** + * 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. * @@ -195,7 +251,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); @@ -214,7 +270,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); @@ -271,4 +327,59 @@ export class FirebaseDownloader { await this.authClient.logout(); this.apiClient = null; } + + // ============================================================================ + // Service Account Management + // ============================================================================ + + /** + * 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, mode: 0o700 }); + await fs.writeFile(savePath, JSON.stringify(serviceAccountJson, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); + + 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..25e2890 100644 --- a/src/lib/services/firebase/index.ts +++ b/src/lib/services/firebase/index.ts @@ -7,7 +7,12 @@ */ // API types (public) -export type { AndroidApp, FirebaseProject, IosApp } from './api'; +export type { + AndroidApp, + FirebaseProject, + IosApp, + ServiceAccountJson, +} from './api'; // Detection and validation export { detectFirebaseConfig, getExpectedPaths } from './detector'; @@ -23,6 +28,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/__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 b58b24c..6ba5a42 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; } @@ -118,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; } } @@ -179,10 +186,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 +265,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 +323,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 +334,17 @@ 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'; + if (message.includes('invalid_grant')) { + throw new Error(`invalid_grant: ${message}`); + } + throw new Error(`token_refresh_failed: ${message}`); + } } if (!tokens.access_token) { @@ -305,21 +362,63 @@ 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, }; } } + /** + * 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/services/firebase/oauth/config.ts b/src/lib/services/firebase/oauth/config.ts index 5d3a1cf..7618918 100644 --- a/src/lib/services/firebase/oauth/config.ts +++ b/src/lib/services/firebase/oauth/config.ts @@ -156,7 +156,8 @@ 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 */ scopes: ['https://www.googleapis.com/auth/firebase'], 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..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/', @@ -221,24 +222,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' }; - -/** - * 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/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/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/services/organization-projects.ts b/src/lib/services/organization-projects.ts new file mode 100644 index 0000000..352c036 --- /dev/null +++ b/src/lib/services/organization-projects.ts @@ -0,0 +1,126 @@ +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 { + const normalized = Math.floor(input ?? NaN); + if (!Number.isFinite(normalized) || normalized <= 0) { + return DEFAULT_PROJECT_FETCH_CONCURRENCY; + } + return normalized; +} + +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/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/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/skills.ts b/src/lib/skills.ts index d39a0c0..681b4f6 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -1,96 +1,55 @@ import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; -import { - EMBEDDED_SKILL_METADATA, - getEmbeddedSkill, - getSkillMetadataByCommand, - hasEmbeddedSkills, - type SkillMetadata, -} from './embedded-skills'; +import type { PreparationContext } from '@/commands/skill/preparation'; +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; + preparationContext?: PreparationContext; } 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?: '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 installation with automatic file modifications', + 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, + visibility: 'public', }, - { - type: 'debug', - name: 'Debug Assistant', - description: 'Interactive debugging assistant', - isLocal: true, - }, - // NOTE: ios-setup is now a LocalJSXCommand in registry.ts, not a skill ]; -/** - * Get all available skills (from embedded metadata + local skills). - */ export function getAvailableSkills(): SkillInfo[] { - const embeddedSkills: SkillInfo[] = EMBEDDED_SKILL_METADATA.map((meta) => ({ - type: meta.commandName, - name: meta.displayName, - description: meta.shortDescription || meta.displayName, - isLocal: false, - })); - - return [...embeddedSkills, ...LOCAL_SKILLS]; + 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); } @@ -99,114 +58,23 @@ export function getSkillInfo(type: SkillType): SkillInfo | undefined { return getAvailableSkills().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; -} - -/** - * Check if a skill is a local skill (not from package). - */ function isLocalSkill(skillType: SkillType): boolean { - return LOCAL_SKILLS.some((s) => s.type === skillType); -} - -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; - } -} - -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.`, - ); + return getAvailableSkills().some((skill) => skill.type === skillType && skill.isLocal); } -/** - * 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. 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 `; -/** - * 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) { @@ -214,7 +82,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://', '')), @@ -228,119 +95,254 @@ function readLocalSkillPrompt(skillName: string): string { } } -/** - * Get prompt for the install skill. - * Uses the autonomous installation prompt optimized for one-shot execution. - * 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'; +function formatSetupStepStatus(required: boolean, configured: boolean): string { + if (!required) { + return '- not required'; + } + return configured ? '✓ verified' : '✗ missing'; +} - let prompt = `Project path: ${projectPath}\nTarget platform: ${platform}\n\n`; +function formatProjectPublicApiKey(value: unknown): string { + if (typeof value === 'string' && value.length > 0) { + return value; + } - // Add one-shot instruction for autonomous execution - if (options?.oneShot) { - prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; + 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; + } + } - // Add explicit directive for autonomous execution - prompt += `## EXECUTION MODE: AUTONOMOUS + return 'not configured'; +} -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 +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(''); +} - // Load the autonomous installation prompt from external file - const installPrompt = readLocalSkillPrompt('install'); - prompt += installPrompt; +interface PreparationSectionOptions { + verificationTitle?: string; + footerLines?: string[]; +} - return prompt; +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)}`); + 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, options?.verificationTitle ?? 'Setup 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 && + context.firebase.senderConfigProjectMatched !== false, + )}`, + `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}`, + `Project Public API Key: ${formatProjectPublicApiKey( + context.config.project.public_api_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 + ? context.firebase.senderConfigProjectMatched === false + ? '✗ project mismatch' + : '✓ configured' + : '✗ missing' + }`, + ], + context.firebase.needed, + ); + + 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, + ); + + 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, + ); + + appendOptionalSection( + lines, + 'Missing Setup (handle before build)', + context.missing, + context.missing.length > 0, + ); + + 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'); } -export async function getSkillPrompt( - skillType: SkillType, - options?: SkillOptions, -): Promise { - // Handle local skills - if (isLocalSkill(skillType)) { - return await getLocalSkillPrompt(skillType, options); +function getInstallPrompt(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 = 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, { + 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'; + } + + 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'; - // Get folder name from metadata - const skillFolder = getSkillFolderByCommand(skillType); - if (!skillFolder) { - throw new Error(`Unknown skill type: ${skillType}`); + if (options?.oneShot) { + prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; } - // Read SKILL.md from @clix-so/clix-agent-skills package - const skillMarkdown = readSkillMarkdown(skillFolder); + prompt += readLocalSkillPrompt('install'); + return prompt; +} +function getDoctorPrompt(options?: SkillOptions): string { const projectPath = options?.projectPath ?? process.cwd(); - const platform = options?.platform ?? 'auto-detect'; + const context = options?.preparationContext; - // Build prompt with context - let prompt = `Project path: ${projectPath} -Target platform: ${platform} -`; + 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'; + } - // 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': - // install uses autonomous installation prompt 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( @@ -353,13 +355,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/lib/skills/doctor/SKILL.md b/src/lib/skills/doctor/SKILL.md index 85108d7..db7d143 100644 --- a/src/lib/skills/doctor/SKILL.md +++ b/src/lib/skills/doctor/SKILL.md @@ -2,100 +2,77 @@ 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." + +### 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: -Output the JSON diagnostic, then provide a brief summary with actionable recommendations. +Final Result: HEALTHY | ACTION_NEEDED | FAILED +- Summary: one concise sentence +- Critical issues: number +- Warnings: number +- Recommended next action: single highest-priority action -Use `/firebase` command to interactively check and configure Firebase credentials. +Use 'clix install' to set up SDK integration if not yet installed. diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index 0d718ad..e222770 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -1,206 +1,111 @@ -# Clix SDK Autonomous Installation +# Clix SDK Autonomous Install -You are an autonomous AI agent that installs and integrates the Clix mobile push notification SDK. +You are an autonomous AI agent that handles `clix install` runtime integration work. + +## Scope + +This skill runs after `clix install` preparation has validated required setup. + +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 ## 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. Do not ask 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 +## Integration Workflow + +### 1. Validate context and project type + +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 +- `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. Add Clix SDK dependency + +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 -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 `/firebase` command in interactive mode to check and configure Firebase credentials. - -## 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. +Add initialization code in the real app startup path. -### iOS Notification Service Extension (Recommended) - -For rich push notifications (images, buttons), create a Notification Service Extension: +Use provided values from preparation context when available: +- Clix Project ID +- Clix Project Public API Key -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) - } +If values are not available, use placeholders: +- `YOUR_CLIX_PROJECT_ID` +- `YOUR_CLIX_PUBLIC_API_KEY` - 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 +### 4. Run post-change dependency commands -For detailed setup, run `clix ios-setup` or `/ios-setup` in interactive mode. +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 and fix failures + +Verify: +- 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: +- apply concrete file changes +- keep edits minimal and project-specific +- preserve existing code style + +Do not: +- redo preparation tasks +- ask user to manually apply 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 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/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/__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/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/lib/utils/oauth.ts b/src/lib/utils/oauth.ts index 91f7756..ba9e1e0 100644 --- a/src/lib/utils/oauth.ts +++ b/src/lib/utils/oauth.ts @@ -5,7 +5,9 @@ */ 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'; // ============================================================================ // 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; } @@ -188,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 = { @@ -237,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) { @@ -258,12 +271,12 @@ export class OAuthCallbackServer { // Handle OAuth error if (error) { - const errorMsg = errorDescription || error; + const errorMsg = errorDescription ? `${error}: ${errorDescription}` : error; + this.logOAuthCallbackError(error, errorDescription, req.url); 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(`OAuth error: ${errorMsg}`)); return; } @@ -271,9 +284,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; } @@ -281,26 +293,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; @@ -313,4 +341,55 @@ 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); + } + + 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(), + ); + } } 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/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/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx index eb01aaa..06d50d1 100644 --- a/src/ui/IosSetupUI.tsx +++ b/src/ui/IosSetupUI.tsx @@ -1,47 +1,51 @@ -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, buildAgentContext, type CapabilitySyncResult, - createAuthContext, + ensureMainTargetEntitlementsLink, generateAppGroupId, getAppleApiErrorMessage, getEntitlementsPath, getIosProjectDir, type IosProjectInfo, - loadApiKeyFromFile, + type MainTargetEntitlementsLinkResult, readEntitlements, syncCapabilities, updateEntitlementsForClix, } from '@/lib/ios'; +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'; import { Header } from '@/ui/components/Header'; import { StatusMessage } from '@/ui/components/StatusMessage'; +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' | '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'; } @@ -60,71 +64,91 @@ 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 { 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; portalResult: CapabilitySyncResult | null; + portalSkipped: boolean; + portalSkipReason: string | null; + authStatusMessage: string; + mainTargetEntitlementsLink: MainTargetEntitlementsLinkResult | null; updatedFiles: string[]; errorMessage: string; + appleId: string; + password: string; + authError: string | null; + analysisResult: AnalysisResult | null; +} + +interface EntitlementsUpdateResult { + files: string[]; + entitlementsPath: string; + iosDir: string; +} + +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 + * Run project analysis (Phase 1 of setup). */ -async function syncWithPortal( - options: IosSetupOptions, - bundleId: string, - appGroupId: string, - setPhase: (phase: SetupPhase) => void, -): Promise { - // Check for partial credentials - user provided some but not all - const hasAnyCreds = !!(options.apiKeyPath || options.keyId || options.issuerId); - const hasAllCreds = !!(options.apiKeyPath && options.keyId && options.issuerId); - - if (!options.skipPortal && hasAnyCreds && !hasAllCreds) { - throw new Error( - 'Incomplete portal credentials. Provide --api-key, --key-id, and --issuer-id together, or use --skip-portal.', - ); - } +async function runAnalysis(options: IosSetupOptions): Promise { + const analysisResult = await analyzeIosProject(process.cwd()); - if (options.skipPortal || !hasAllCreds) { - return null; + if (!analysisResult.success || !analysisResult.project) { + throw new Error(analysisResult.error || 'Failed to analyze iOS project'); } - // 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, - }, - }); + const project = analysisResult.project; + const bundleId = options.bundleId || project.bundleId; + const appGroupId = generateAppGroupId(bundleId); - setPhase('syncing'); - return await syncCapabilities(authContext, bundleId, appGroupId); + 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, @@ -155,42 +179,38 @@ 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: [], + changeSummary: { + changedFiles: [], + 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'); + 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'); } - const project = analysisResult.project; - setState((s) => ({ ...s, projectInfo: project })); - result.projectInfo = project; - - const bundleId = options.bundleId || project.bundleId; - const appGroupId = generateAppGroupId(bundleId); - - // Phase 2: Sync with Apple Developer Portal (if not skipped) - const syncResult = await syncWithPortal(options, bundleId, appGroupId, (phase) => - setState((s) => ({ ...s, phase })), - ); - if (syncResult) { - setState((s) => ({ ...s, portalResult: syncResult })); - result.portalSync = syncResult; - } - - // Phase 3: Update local entitlements files + // Update local entitlements files setState((s) => ({ ...s, phase: 'updating_entitlements' })); const entitlementsResult = await updateEntitlements(project, bundleId, options); @@ -203,6 +223,27 @@ async function runSetup( const files = entitlementsResult.files; setState((s) => ({ ...s, updatedFiles: files })); result.entitlementsUpdated = files; + result.changeSummary?.changedFiles.push(...files); + + // 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( @@ -215,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()); @@ -225,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; @@ -240,18 +277,81 @@ 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, portalResult: null, + portalSkipped: false, + portalSkipReason: null, + authStatusMessage: '', + mainTargetEntitlementsLink: null, updatedFiles: [], errorMessage: '', + appleId: '', + password: '', + authError: null, + analysisResult: null, }); const [result, setResult] = useState(null); + const authContextRef = useRef(appleAuthContext ?? null); + + const { + phase, + projectInfo, + portalResult, + portalSkipped, + portalSkipReason, + authStatusMessage, + mainTargetEntitlementsLink, + updatedFiles, + errorMessage, + analysisResult, + } = state; + + // 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], + ); - const { phase, projectInfo, portalResult, updatedFiles, errorMessage } = state; + useCancelInput(() => handleSkipPortalSync('Authentication cancelled by user'), { + isActive: isAuthPhase, + }); // Handle user input for complete/error phases const handleContinue = useCallback(() => { @@ -270,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' })); @@ -282,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 ( @@ -291,11 +533,120 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = {/* Phase: Analyzing */} {phase === 'analyzing' && } - {/* Phase: Authenticating */} - {phase === 'authenticating' && ( + {/* Phase: Apple ID Input */} + {phase === 'auth_apple_id_input' && ( - + + + Apple Developer Account Login + + + 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 + )} @@ -312,7 +663,11 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = {phase === 'updating_entitlements' && ( - + )} @@ -322,8 +677,10 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = )} @@ -355,10 +712,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 ( @@ -381,58 +748,132 @@ 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, updatedFiles, skipPortal }) => ( - - +}> = ({ + projectInfo, + portalResult, + portalSkipped, + portalSkipReason, + mainTargetEntitlementsLink, + updatedFiles, +}) => { + const nextSteps: string[] = []; + + 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.', + ); + } - {portalResult && ( - - - {portalResult.enabled.length > 0 && ( - - Enabled: {portalResult.enabled.join(', ')} - - )} - {portalResult.appGroupCreated && portalResult.appGroupId && ( - - Created App Group: {portalResult.appGroupId} - - )} - - )} + 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.', + ); + } - {skipPortal && ( - - )} + 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).', + ); - - {updatedFiles.map((file) => ( - - • {file} - - ))} + return ( + + - - - ✓ iOS setup completed successfully! - - + {portalResult && ( + + + {portalResult.enabled.length > 0 && ( + + Enabled: {portalResult.enabled.join(', ')} + + )} + {portalResult.appGroupCreated && portalResult.appGroupId && ( + + Created App Group: {portalResult.appGroupId} + + )} + + )} + + {portalSkipped && ( + + + {portalSkipReason ? ( + + Reason: {portalSkipReason} + + ) : null} + + )} + + + {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 f30b1d8..cdec934 100644 --- a/src/ui/LoginUI.tsx +++ b/src/ui/LoginUI.tsx @@ -18,10 +18,15 @@ 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'; import { StatusMessage } from '@/ui/components/StatusMessage'; +import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; type LoginPhase = | 'checking_existing' @@ -33,11 +38,6 @@ type LoginPhase = | 'complete' | 'error'; -interface OrgWithProjects { - org: Organization; - projects: Project[]; -} - interface LoginUIProps { /** Called when login completes successfully */ onComplete?: (credentials: ClixCredentials) => void; @@ -71,22 +71,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(); @@ -115,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) => { @@ -130,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: { @@ -144,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(), @@ -154,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(() => { @@ -241,14 +225,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 @@ -310,14 +303,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 005b773..a67460f 100644 --- a/src/ui/SetupUI.tsx +++ b/src/ui/SetupUI.tsx @@ -17,10 +17,15 @@ 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'; import { StatusMessage } from '@/ui/components/StatusMessage'; +import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; type SetupPhase = | 'checking_auth' @@ -33,11 +38,6 @@ type SetupPhase = | 'complete' | 'error'; -interface OrgWithProjects { - org: Organization; - projects: Project[]; -} - interface SetupUIProps { /** Called when setup completes successfully */ onComplete?: () => void; @@ -73,22 +73,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'); @@ -101,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) => { @@ -118,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: { @@ -132,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(), @@ -142,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'); @@ -185,15 +169,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 @@ -293,14 +277,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/ChatApp.tsx b/src/ui/chat/ChatApp.tsx deleted file mode 100644 index 47900ba..0000000 --- a/src/ui/chat/ChatApp.tsx +++ /dev/null @@ -1,324 +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 { FirebaseWizard } from '../components/FirebaseWizard'; -import { IosSetupFlow } from '../components/IosSetupFlow'; -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, - } = 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], - ); - - // Overlay management - const overlays = useOverlays({ - currentAgent, - onTransferSuccess: handleTransferSuccess, - addSystemMessage, - transferSession, - switchAgent: chatActions.switchAgent, - resumeSession: chatActions.resumeSession, - executeDebugSession: chatActions.executeDebugSession, - }); - - // 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 && ( - - )} - - - - - - {/* Overlays */} - {overlays.activeOverlay === 'agent' && ( - - )} - {overlays.activeOverlay === 'transfer' && ( - - )} - {overlays.activeOverlay === 'resume' && ( - - )} - {overlays.activeOverlay === 'mcp' && ( - - )} - {overlays.activeOverlay === 'debug' && ( - - )} - {overlays.activeOverlay === 'firebase' && ( - - )} - {overlays.activeOverlay === 'ios-setup' && ( - { - overlays.handleIosSetupComplete(result.message); - }} - /> - )} - {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 0ff488b..0000000 --- a/src/ui/chat/components/ChatInput.tsx +++ /dev/null @@ -1,250 +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], - ); - - 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; - - // Single Ctrl+C - clear input - if (value) { - setValue(''); - setSelectedCommandIndex(0); - setInputKey((prev) => prev + 1); - } - return; - } - - // Handle escape: clear input (always, regardless of menu state) - if (key.escape) { - if (value) { - setValue(''); - setSelectedCommandIndex(0); - setInputKey((prev) => prev + 1); - } - return; - } - - // Handle menu navigation when menu is visible - if (showMenu && filteredCommands.length > 0) { - if (handleMenuNavigation(key)) return; - } - - // Handle history navigation (only when not in slash mode) - if (!showMenu) { - handleHistoryNavigation(key); - } - }); - - 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}`); - setValue(''); - setSelectedCommandIndex(0); - return; - } - } - - onSubmit(submittedValue.trim()); - setValue(''); - setSelectedCommandIndex(0); - }, - [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/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/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 27219a8..0000000 --- a/src/ui/chat/hooks/useChatActions.ts +++ /dev/null @@ -1,128 +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, - 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 9dc4c5a..0000000 --- a/src/ui/chat/hooks/useCommandHandler.ts +++ /dev/null @@ -1,252 +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' - | 'showFirebaseWizard' - | 'showIosSetupOverlay' - | '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, - showFirebaseWizard, - showIosSetupOverlay, - 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 'firebase': - showFirebaseWizard(); - return; - - case 'ios-setup': - showIosSetupOverlay(); - return; - - case 'login': - showLoginOverlay(); - return; - - case 'logout': - showLogoutOverlay(); - return; - - case 'whoami': - showWhoamiOverlay(); - return; - - case 'update': - handleUpdateCommand(addSystemMessage); - return; - - case 'exit': - onExit(); - exit(); - return; - - default: - // Handle skill commands - 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, - showFirebaseWizard, - showIosSetupOverlay, - 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 6e67118..0000000 --- a/src/ui/chat/hooks/useMessageSending.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Message sending hook for user message submission. - */ -import { useCallback } from 'react'; -import { getDebugPrompt } from '../../../lib/services/debug-service'; -import { executeSkill as executeSkillLib, getSkillInfo, 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'; - -/** - * 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 executeSkill = useCallback( - async (skillType: SkillType) => { - if (!executorRef.current) { - addSystemMessage('No agent configured. Please run "clix config" to select an agent.'); - return; - } - - const skillInfo = getSkillInfo(skillType); - if (!skillInfo) { - addSystemMessage(`Unknown skill: ${skillType}`); - return; - } - - // 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 - }); - - 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 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, - 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 01d03c8..0000000 --- a/src/ui/chat/hooks/useMessageStreaming.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Message streaming hook for handling streamed agent responses. - */ -import { useCallback } from 'react'; -import type { AgentMessage } from '../../../lib/executor'; -import { generateMessageId, useChatContext } from '../context/ChatContext'; -import type { ChatRefs } from './types'; - -export interface StreamingOptions { - signal?: AbortSignal; -} - -/** - * 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 = ''; - // 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) { - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - updates: { - content: `${accumulatedContent}\n\n[Interrupted]`, - status: 'complete', - }, - }, - }); - // Mark any remaining pending tools as complete - for (const toolId of pendingToolIds) { - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: toolId, - updates: { status: 'complete' }, - }, - }); - } - 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; - } - } - - // Ensure the message is marked complete (if not aborted) - if (!signal?.aborted) { - dispatch({ - type: 'UPDATE_MESSAGE', - payload: { - id: agentMessageId, - 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' }, - }, - }); - } - } - }, - [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 a8af511..0000000 --- a/src/ui/chat/hooks/useOverlays.ts +++ /dev/null @@ -1,346 +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 { 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' - | 'firebase' - | 'ios-setup' - | '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']; - executeDebugSession: ReturnType['executeDebugSession']; -} - -/** - * Hook for managing overlay/modal UI state. - */ -export function useOverlays(options: UseOverlaysOptions) { - const { - currentAgent, - onTransferSuccess, - addSystemMessage, - transferSession, - switchAgent, - resumeSession, - executeDebugSession, - } = 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 showFirebaseWizard = useCallback(() => setActiveOverlay('firebase'), []); - const showIosSetupOverlay = useCallback(() => setActiveOverlay('ios-setup'), []); - 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], - ); - - // 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], - ); - - // 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, - - // Firebase wizard - showFirebaseWizard, - handleFirebaseComplete, - handleFirebaseCancel, - - // iOS setup - showIosSetupOverlay, - handleIosSetupComplete, - - // 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/AppleLoginUI.tsx b/src/ui/components/AppleLoginUI.tsx index 179a3b5..e83cd40 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, marginY={1} > - Create APNS Key with Apple Account + Choose APNS Key Creation Method @@ -219,21 +315,21 @@ export const AppleLoginUI: React.FC = ({ 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/chat/components/MessageList.tsx b/src/ui/components/ChatMessageList.tsx similarity index 55% rename from src/ui/chat/components/MessageList.tsx rename to src/ui/components/ChatMessageList.tsx index 49cfb2d..d2f2d44 100644 --- a/src/ui/chat/components/MessageList.tsx +++ b/src/ui/components/ChatMessageList.tsx @@ -1,27 +1,85 @@ import { Box, Text } from 'ink'; import type React from 'react'; -import { BashOutputDisplay } from '@/ui/components/BashOutputDisplay'; -import { ToolCallDisplay } from '@/ui/components/ToolCallDisplay'; -import type { ChatMessage } from '../context/ChatContext'; -import { AgentMessage } from './AgentMessage'; -import { UserMessage } from './UserMessage'; - -interface MessageListProps { - messages: ChatMessage[]; +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: ChatMessage[]): ChatMessage[] { - const result: ChatMessage[] = []; +function filterToolMessages(messages: ChatMessageListMessage[]): ChatMessageListMessage[] { + const result: ChatMessageListMessage[] = []; let completedToolCount = 0; - // Find the last pending/streaming tool - let lastPendingTool: ChatMessage | null = null; + let lastPendingTool: ChatMessageListMessage | null = null; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if ( @@ -36,17 +94,12 @@ function filterToolMessages(messages: ChatMessage[]): ChatMessage[] { 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', @@ -62,8 +115,7 @@ function filterToolMessages(messages: ChatMessage[]): ChatMessage[] { return result; } -// Helper function to render a single message -function renderMessage(message: ChatMessage, agentName?: string) { +function renderMessage(message: ChatMessageListMessage, agentName?: string): React.ReactNode { switch (message.role) { case 'user': return ; @@ -112,31 +164,24 @@ function renderMessage(message: ChatMessage, agentName?: string) { } } -export const MessageList: React.FC = ({ messages, maxHeight, agentName }) => { +const ChatMessageListInternal: React.FC = ({ + messages, + maxHeight, + agentName, + emptyState, +}) => { if (messages.length === 0) { - return ( - - - Tip: - - Type / to open the command popup; Tab autocompletes slash - commands. - - - - ); + return <>{emptyState ?? DEFAULT_EMPTY_STATE}; } - // 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))} ); }; + +export const ChatMessageList = memo(ChatMessageListInternal); diff --git a/src/ui/components/FirebaseConfigFilesSetup.tsx b/src/ui/components/FirebaseConfigFilesSetup.tsx new file mode 100644 index 0000000..ec2621e --- /dev/null +++ b/src/ui/components/FirebaseConfigFilesSetup.tsx @@ -0,0 +1,825 @@ +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, 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'; +import { FirebaseStatusDisplay } from './FirebaseStatusDisplay'; + +export { + hasValidFirebaseConfigFiles, + platformNeedsAndroidWithUnknown, + platformNeedsIosWithUnknown, +} from './firebase-detection-utils'; + +export interface NoAppsContext { + noAndroidApps: boolean; + noIosApps: boolean; + needsAndroid: boolean; + needsIos: boolean; +} + +function DetectingPhase(): React.ReactElement { + return ( + + + Firebase Configuration Files + + + + + + Detecting Firebase configuration files... + + + ); +} + +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 (!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'} + + + )} + + {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'} + + )} + + + ); +} + +function AuthenticatingPhase({ + onCancel, + authUrl, +}: { + onCancel: () => void; + authUrl?: string | null; +}): React.ReactElement { + useCancelInput(onCancel); + const reopenLink = authUrl ? formatTerminalHyperlink(authUrl, authUrl) : null; + + return ( + + + + 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 + + + + ); +} + +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, + 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(defaultDisplayName ?? ''); + 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({ + onOpenProjectCreation, + onRetry, + onCancel, +}: { + onOpenProjectCreation: () => void; + onRetry: () => void; + onCancel: () => void; +}): React.ReactElement { + const items = [ + { label: '🆕 Open Firebase Project Creation', value: 'create' }, + { label: '↻ Retry project list', value: 'retry' }, + { label: '← Back', value: 'cancel' }, + ]; + + useCancelInput(onCancel); + + const handleSelect = (item: { value: string }) => { + switch (item.value) { + case 'create': + onOpenProjectCreation(); + break; + case 'retry': + onRetry(); + break; + default: + onCancel(); + break; + } + }; + + return ( + + + + No Firebase Projects Found + + + + No Firebase projects are associated with this account. + + + 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 + + + + ↑↓ navigate · Enter select · Esc/Ctrl+C cancel + + + ); +} + +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 { + AppSelectorPhase as FirebaseConfigAppSelectorTask, + AuthenticatingPhase as FirebaseConfigAuthenticatingTask, + CreateAppInputPhase as FirebaseConfigCreateAppInputTask, + CreatingAppPhase as FirebaseConfigCreatingAppTask, + DetectingPhase as FirebaseConfigDetectingTask, + DownloadingPhase as FirebaseConfigDownloadingTask, + ErrorPhase as FirebaseConfigErrorTask, + IosAppUpdateConfirmationPhase as FirebaseIosAppUpdateConfirmation, + IosAppUpdatePhase as FirebaseIosAppUpdateTask, + 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 + + + ); +} + +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/FirebaseServiceAccountSetup.tsx b/src/ui/components/FirebaseServiceAccountSetup.tsx new file mode 100644 index 0000000..d496f79 --- /dev/null +++ b/src/ui/components/FirebaseServiceAccountSetup.tsx @@ -0,0 +1,409 @@ +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, useState } from 'react'; +import { openBrowser } from '@/lib/auth/browser'; +import { + parseServiceAccountJson, + type ServiceAccountJson, + type ServiceAccountValidationResult, +} from '@/lib/services/firebase'; +import { useCancelInput } from '@/ui/hooks'; +import { readTextFileFromInputPath } from './file-input-utils'; + +export { hasValidFirebaseConfigFiles } from './firebase-detection-utils'; + +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(''); + 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); + 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: + {serviceAccountConsoleUrl} + + + 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 a17f645..0000000 --- a/src/ui/components/FirebaseWizard.tsx +++ /dev/null @@ -1,1418 +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 IosApp, - isOAuthConfigured, - platformNeedsAndroid, - platformNeedsIos, - 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 { GenericSelector, type SelectorItem } from './GenericSelector'; - -interface FirebaseWizardProps { - projectPath: string; - projectType?: ProjectType; - onComplete: (result: FirebaseSetupResult) => void; - onCancel?: () => void; -} - -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'; - -/** - * 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'; -} - -/** - * 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' }, - }); - } - - // 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' }, - }); - } - - // 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' }, - }); - } - items.push({ - id: 'help-ios', - label: 'Help: Download GoogleService-Info.plist', - action: { type: 'help', topic: 'downloadConfig' }, - }); - } - - // 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... - - - ); -} - -/** - * 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.`; - } - 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} - - )} - - 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, - 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'); - - 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('status'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Detection failed'); - setPhase('error'); - } - }; - - if (phase === 'detecting') { - detect(); - } - }, [phase, projectPath, propProjectType]); - - const handleContinue = useCallback(() => { - setPhase('menu'); - }, []); - - const handleSkip = useCallback(() => { - setSkipped(true); - setPhase('complete'); - 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('error'); - return; - } - - if (androidApp && iosApp) { - setDownloadingPlatform('both'); - } else if (androidApp) { - setDownloadingPlatform('android'); - } else { - setDownloadingPlatform('ios'); - } - setPhase('downloading'); - - 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('status'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Download failed'); - setPhase('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('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('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('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('error'); - return; - } - - try { - const { noAndroidApps, noIosApps } = await fetchAppsForPlatforms( - project, - shouldFetchAndroid, - shouldFetchIos, - ); - - if ((shouldFetchAndroid && noAndroidApps) || (shouldFetchIos && noIosApps)) { - setNoAppsContext({ - noAndroidApps, - noIosApps, - needsAndroid: shouldFetchAndroid, - needsIos: shouldFetchIos, - }); - setPhase('no_apps_found'); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch apps'); - setPhase('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'); - - 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('error'); - return; - } - } - - // Fetch projects - const fetchedProjects = await downloader.listProjects(); - if (fetchedProjects.length === 0) { - setError('No Firebase projects found for this account.'); - setPhase('error'); - return; - } - - setProjects(fetchedProjects); - - // Auto-select if only one project - if (fetchedProjects.length === 1) { - projectSelectRef.current(fetchedProjects[0]); - } else { - setPhase('select_project'); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Authentication failed'); - setPhase('error'); - } - }, [downloader]); - - const handleAction = useCallback( - async (action: CredentialAction) => { - switch (action.type) { - case 'download': - await handleDownload(); - break; - - case 'redetect': - setPhase('detecting'); - break; - - case 'redetect_platform': - setValidatingPlatform(action.platform); - setPhase('detecting'); - break; - - case 'validate': - setValidatingPlatform(action.platform); - setPhase('validating'); - // Re-detect to validate - try { - if (service) { - const newResult = await service.detect(); - setResult(newResult); - } - setPhase('status'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Validation failed'); - setPhase('error'); - } - break; - - case 'help': { - const url = FIREBASE_HELP_URLS[action.topic]; - openBrowser(url); - break; - } - - case 'skip': - handleSkip(); - break; - - case 'done': - setPhase('complete'); - onComplete({ - completed: true, - skipped: false, - detection: result, - }); - break; - } - }, - [service, handleSkip, handleDownload, onComplete, result], - ); - - const handleRetry = useCallback(async () => { - // Check if this was a scope error - if so, logout and re-authenticate - if (error && isScopeInsufficientError(error)) { - // 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('detecting'); - }, [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'); - - 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('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('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('error'); - } - }, - [selectedProject, downloader, getPlatformNeeds, noAppsContext, selectedAndroidApp], - ); - - // Handlers for no apps found phase - const handleStartCreateAndroid = useCallback(() => { - setCreatingAppPlatform('android'); - setPhase('create_android_app'); - }, []); - - const handleStartCreateIos = useCallback(() => { - setCreatingAppPlatform('ios'); - setPhase('create_ios_app'); - }, []); - - 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('select_project'); - }, []); - - 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 ; - - 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/GuidedSetupWizard.tsx b/src/ui/components/GuidedSetupWizard.tsx deleted file mode 100644 index 8d3e942..0000000 --- a/src/ui/components/GuidedSetupWizard.tsx +++ /dev/null @@ -1,407 +0,0 @@ -/** - * Guided Setup Wizard - Replaces AI Agent for Xcode configuration - * Provides step-by-step guidance for manual Xcode tasks - */ - -import { Box, Text, useInput } 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 { generatePodfileSnippet } from '@/lib/ios/extension-templates'; -import { isCtrlCInput } from '@/ui/hooks'; -import { Header } from './Header'; -import { StatusMessage } from './StatusMessage'; - -export type GuidedSetupPhase = - | 'creating_files' - | 'xcode_target' - | 'build_settings' - | 'dependencies' - | 'verification' - | 'complete'; - -export interface GuidedSetupContext { - bundleId: string; - appGroupId: string; - appName: string; - iosDir: string; - entitlementsPath: string; - 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<{ - extensionName: string; - extensionBundleId: string; - extensionResult: ExtensionGeneratorResult | null; - appGroupId: string; -}> = ({ extensionName, extensionBundleId, extensionResult, appGroupId }) => ( - - {extensionResult && extensionResult.createdFiles.length > 0 && ( - - - {extensionResult.createdFiles.map((file) => ( - - • {file} - - ))} - - )} - - - - 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... - - -); - -const BuildSettingsGuide: React.FC<{ - extensionName: string; - entitlementsPath: string; -}> = ({ extensionName, entitlementsPath }) => ( - - - - - - Step 2: Configure 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... - - -); - -const DependenciesGuide: React.FC<{ - extensionName: string; -}> = ({ extensionName }) => { - const podfileSnippet = generatePodfileSnippet(extensionName); - - return ( - - - - - - Step 3: Add Clix SDK to Extension Target - - - - - For CocoaPods projects: - Add to your Podfile: - - {podfileSnippet} - - - Then run: cd ios && pod install - - - - - 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... - - - ); -}; - -const VerificationPhase: React.FC<{ - context: GuidedSetupContext; - extensionResult: ExtensionGeneratorResult | null; -}> = ({ context, extensionResult }) => { - const verification = verifyExtensionFiles(context.iosDir, context.appName); - - return ( - - - - - - Step 4: Verification - - - - - Extension Files: - {verification.complete ? ( - - ) : ( - - - {verification.missingFiles.map((file) => ( - - • {file} - - ))} - - )} - - - - Extension Directory: - {extensionResult?.extensionDir} - - - - Important Reminders: - - • Replace YOUR_PROJECT_ID in NotificationService.swift - - • Extension must share the same App Group as main app - • Regenerate provisioning profiles if needed - - - - Press Enter to finish... - - - ); -}; - -const CompletePhase: React.FC<{ - error: string | null; - extensionResult: ExtensionGeneratorResult | null; -}> = ({ error, extensionResult }) => ( - - {error ? ( - - ) : ( - - - - ✓ Extension setup guide complete! - - - {extensionResult && extensionResult.createdFiles.length > 0 && ( - - Created files: - {extensionResult.createdFiles.map((file) => ( - - • {file} - - ))} - - )} - - )} - -); diff --git a/src/ui/components/InstallPreparationUI.tsx b/src/ui/components/InstallPreparationUI.tsx new file mode 100644 index 0000000..cac334a --- /dev/null +++ b/src/ui/components/InstallPreparationUI.tsx @@ -0,0 +1,3244 @@ +/** + * Install preparation UI component. + * + * Uses a single task orchestrator to enforce required setup order + * before SDK installation. + * + * @module ui/components/InstallPreparationUI + */ + +import * as path from 'node:path'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +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 { + 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'; +import { + type AndroidApp, + FIREBASE_HELP_URLS, + type FirebaseDetectionResult, + FirebaseDownloader, + type FirebaseProject, + FirebaseService, + type GoogleServiceInfoPlist, + type GoogleServicesJson, + type IosApp, + isOAuthConfigured, + parseBase64ServiceAccountJson, + 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 { + FirebaseConfigAppSelectorTask, + FirebaseConfigAuthenticatingTask, + FirebaseConfigCreateAppInputTask, + FirebaseConfigCreatingAppTask, + FirebaseConfigDetectingTask, + FirebaseConfigDownloadingTask, + FirebaseConfigErrorTask, + type NoAppsContext as FirebaseConfigNoAppsContext, + FirebaseConfigNoAppsFoundTask, + FirebaseConfigNoProjectsTask, + FirebaseConfigProjectSelectorTask, + FirebaseConfigStatusTask, + FirebaseIosAppUpdateConfirmation, + FirebaseIosAppUpdateTask, + platformNeedsAndroidWithUnknown as firebaseConfigNeedsAndroid, + platformNeedsIosWithUnknown as firebaseConfigNeedsIos, + hasValidFirebaseConfigFiles as hasValidFirebaseConfigTaskFiles, +} from './FirebaseConfigFilesSetup'; +import { + FirebaseServiceAccountCheckingSenderConfigTask, + FirebaseServiceAccountDetectingTask, + FirebaseServiceAccountErrorTask, + FirebaseServiceAccountPasteTask, + FirebaseServiceAccountRegisteredTask, + FirebaseServiceAccountRegisteringTask, + FirebaseServiceAccountRegistrationFailedTask, + FirebaseServiceAccountSavingTask, + hasValidFirebaseConfigFiles as hasValidServiceAccountConfigFiles, +} from './FirebaseServiceAccountSetup'; +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, + type IosEntitlementsTaskResult, +} from './ios-setup/IosEntitlementsTask'; +import { + NotificationExtensionBuildSettingsTask, + NotificationExtensionCompleteTask, + NotificationExtensionDependenciesTask, + type NotificationExtensionSetupContext, + type NotificationExtensionVerificationChecks, + 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'; + +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_error' + | 'apns_detecting' + | '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' + | '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' + | 'install_skill_running' + | 'install_skill_failed' + | 'install_skill_succeeded'; + +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; +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; + mode?: InstallPreparationMode; + chatMessages?: ChatMessageListMessage[]; + onRunInstallSkill: (context: PreparationContext) => Promise; + onComplete: (context: PreparationContext) => void; + onCancel: () => void; +} + +export type InstallPreparationMode = 'chat-full' | 'command-prep-only'; + +export interface ProjectBuildTaskResult { + success: boolean; + aborted?: boolean; + error?: string; +} + +interface ActionItem extends SelectorItem { + action: 'continue' | 'cancel'; +} + +export type StatusLineState = 'pending' | 'checking' | 'ok' | 'missing' | 'skipped'; + +export interface StatusRow { + label: string; + status: StatusLineState; + detail?: string; +} + +interface IosGuidedContextCache { + bundleId: string; + appGroupId: string; + appName: string; + iosDir: string; + xcodeprojPath: string; + projectId: string; + entitlementsPath: string; +} + +interface TaskCompletionPatch { + ios?: Partial; + apns?: Partial; +} + +interface FirebaseConfigState { + detection: FirebaseDetectionResult | null; + projectType: ProjectType | null; + service: FirebaseService | null; + authUrl: string | null; + projects: FirebaseProject[]; + selectedProject: FirebaseProject | null; + androidApps: AndroidApp[]; + iosApps: IosApp[]; + selectedAndroidApp: AndroidApp | null; + selectedIosApp: IosApp | null; + 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; +} + +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; + 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; + includeRuntimeTasks: boolean; +} + +interface StartTaskOverrideValidationResult { + taskId: InstallTaskId | null; + note: string | null; +} + +async function buildNotificationExtensionContext( + projectPath: string, + iosBundleId: string | undefined, + iosAppGroupId: string | undefined, + clixProjectId: string | undefined, + cachedContext: IosGuidedContextCache | null, +): Promise { + if (cachedContext?.projectId) { + return cachedContext; + } + + const analysis = await analyzeIosProject(projectPath); + if (!analysis.success || !analysis.project) { + throw new Error(analysis.error || 'Failed to analyze iOS project'); + } + + 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); + const projectId = clixProjectId || ''; + if (!projectId) { + throw new Error('Clix Project ID is required for Notification Service Extension setup.'); + } + + return { + bundleId, + appGroupId, + appName: analysis.project.appName, + iosDir, + xcodeprojPath: analysis.project.projectPath, + projectId, + 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, + runtimeTaskState: RuntimeTaskStateMap, +): 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': + 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 '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; + } +} + +export function getStatusRows( + context: PreparationContext, + layoutPolicy: StatusLayoutPolicy, + runtimeTaskState: RuntimeTaskStateMap, + includeRuntimeTasks: boolean, +): 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, + }); + } + + const taskRows: StatusRow[] = getApplicableInstallTasks(context, { + includeRuntimeTasks, + }).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]; +} + +export function StatusLine({ + label, + status, + detail, +}: { + label: string; + status: StatusLineState; + 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, + 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, 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; + const primaryActionLabel = + nextTaskId === null + ? 'Finish install' + : nextTaskId === 'install_skill' + ? 'Continue to SDK installation' + : 'Continue required setup'; + + const items: ActionItem[] = [ + { + id: 'continue', + label: primaryActionLabel, + action: 'continue', + }, + { id: 'cancel', label: 'Cancel', action: 'cancel' }, + ]; + + const handleSelect = useCallback( + (item: ActionItem) => { + if (item.action === 'cancel') { + onCancel(); + } else { + onContinue(); + } + }, + [onCancel, onContinue], + ); + + return ( + + Install Preparation + + {statusRows.map((row) => ( + + ))} + + + {showMissingList && ( + + Missing setup: + {context.missing.map((item) => ( + + • {item} + + ))} + + )} + + {showMissingSummary && ( + + + Missing setup: {context.missing.length} item{context.missing.length === 1 ? '' : 's'} + + + )} + + {note ? ( + + {note} + + ) : null} + + + + ); +} + +function TaskHeader({ title, subtitle }: { title: string; subtitle: string }): React.ReactElement { + return ( + + {title} + {subtitle} + + ); +} + +function getTaskMessages( + chatMessages: ChatMessageListMessage[] | undefined, + startIndex: number, +): ChatMessageListMessage[] { + if (!chatMessages || chatMessages.length <= startIndex) { + return []; + } + 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': + 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'; + case 'install_skill': + return 'install_skill_running'; + default: + return 'firebase_config_detecting'; + } +} + +function createInitialFirebaseConfigState(): FirebaseConfigState { + return { + detection: null, + projectType: null, + service: null, + authUrl: null, + projects: [], + selectedProject: null, + androidApps: [], + iosApps: [], + selectedAndroidApp: null, + selectedIosApp: null, + downloadingPlatform: 'both', + noAppsContext: null, + creatingAppPlatform: 'android', + postAuthAction: 'none', + 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, + 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') || + error.includes('insufficient authentication scopes') || + (error.includes('403') && error.includes('PERMISSION_DENIED')) || + error.includes('invalid_grant') + ); +} + +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, + invalidEnvStartTask, + taskOverrideEnabled, + includeRuntimeTasks, +}: 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.`, + }; + } + + 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, + note: `Task override "${effectiveStartTaskId}" is not applicable for this project (${formatProjectType(context.projectType)}).`, + }; + } + + 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.config.project.id) { + return { + taskId: null, + note: 'Task override requires a linked Clix Project ID. Run setup/login 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, + mode = 'chat-full', + chatMessages, + 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 = + 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 installSkillCompletionHandledRef = useRef(false); + const installSkillRunStartedRef = useRef(false); + + 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 [runtimeTaskState, setRuntimeTaskState] = useState({}); + const [installSkillError, setInstallSkillError] = useState(null); + 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()); + + 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((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((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( + (taskId: InstallTaskId, preparationContext: PreparationContext) => { + setNote(null); + nseCompletionHandledRef.current = false; + installSkillCompletionHandledRef.current = false; + if (isRuntimeTask(taskId)) { + setRuntimeTaskState((prev) => ({ ...prev, [taskId]: 'running' })); + } + if (taskId === 'install_skill') { + installSkillRunStartedRef.current = false; + setInstallSkillError(null); + setInstallSkillMessageStartIndex(chatMessageCountRef.current); + } + setActiveTaskId(taskId); + resetTaskRuntimeState(preparationContext); + setActiveLeafTaskId(getInitialLeafTaskId(taskId)); + setPhase('task'); + }, + [resetTaskRuntimeState], + ); + + const loadPreparationContext = useCallback(async (): Promise => { + const nextContext = await gatherPreparationContext(projectPath); + if (!nextContext) { + setContext(null); + setPhase('config_missing'); + return null; + } + + setContext(nextContext); + return nextContext; + }, [projectPath]); + + useEffect(() => { + let mounted = true; + + const check = async () => { + const nextContext = await gatherPreparationContext(projectPath); + if (!mounted) { + return; + } + + if (!nextContext) { + setPhase('config_missing'); + return; + } + + setContext(nextContext); + if (startTaskOverrideHandledRef.current) { + setPhase('status'); + return; + } + startTaskOverrideHandledRef.current = true; + + const overrideDecision = validateStartTaskOverride({ + context: nextContext, + effectiveStartTaskId, + invalidEnvStartTask, + taskOverrideEnabled, + includeRuntimeTasks, + }); + if (!overrideDecision.taskId) { + if (overrideDecision.note) { + setNote(overrideDecision.note); + } + setPhase('status'); + return; + } + + beginTask(overrideDecision.taskId, nextContext); + }; + + void check(); + + return () => { + mounted = false; + }; + }, [ + beginTask, + effectiveStartTaskId, + includeRuntimeTasks, + invalidEnvStartTask, + projectPath, + taskOverrideEnabled, + ]); + + const applyTaskCompletion = useCallback( + async (patch?: TaskCompletionPatch) => { + const latest = await gatherPreparationContext(projectPath); + if (!latest) { + setContext(null); + setActiveTaskId(null); + resetTaskRuntimeState(); + setPhase('config_missing'); + return; + } + + const mergedIos = patch?.ios ? { ...latest.ios, ...patch.ios } : latest.ios; + const mergedApns = patch?.apns ? { ...latest.apns, ...patch.apns } : latest.apns; + + await saveSetupStatus(projectPath, latest.firebase, mergedIos, mergedApns); + + const refreshed = await loadPreparationContext(); + if (!refreshed) { + return; + } + + setActiveTaskId(null); + resetTaskRuntimeState(refreshed); + setNote(null); + setPhase('status'); + }, + [loadPreparationContext, projectPath, resetTaskRuntimeState], + ); + + 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'); + }, + [activeTaskId, cancelFirebaseAuthentication, context, resetTaskRuntimeState], + ); + + const handleContinue = useCallback(() => { + if (!context) { + return; + } + + const nextTaskId = getNextIncompleteTaskId(context, runtimeTaskState, { + includeRuntimeTasks, + }); + if (!nextTaskId) { + onComplete(context); + return; + } + + setNote(null); + beginTask(nextTaskId, context); + }, [beginTask, context, includeRuntimeTasks, onComplete, runtimeTaskState]); + + const handleCancelPreparation = useCallback(() => { + cancelFirebaseAuthentication('Install preparation cancelled'); + onCancel(); + }, [cancelFirebaseAuthentication, onCancel]); + + useCancelInput(handleCancelPreparation, { + isActive: + phase === 'task' && + activeTaskId === 'install_skill' && + activeLeafTaskId === 'install_skill_running', + }); + + 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), + }; + }, + [], + ); + + 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, + selectedIosApp: iosApp ?? prev.selectedIosApp, + })); + 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)) { + const mismatch = getFirebaseConfigIdentifierMismatch(newDetection, context); + if (!mismatch.ios && !mismatch.android) { + await applyTaskCompletion(); + return; + } + setActiveLeafTaskId('firebase_config_status'); + 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, + context, + firebaseConfigDownloader, + firebaseConfigState.projectType, + firebaseConfigState.service, + projectPath, + ], + ); + + const handleFirebaseConfigProjectSelect = useCallback( + 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(); + 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, + context, + 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, + getFirebaseConfigIdentifierMismatch(firebaseConfigState.detection, context), + ); + + 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); + } + }, + [ + context, + firebaseConfigDownloader, + firebaseConfigState.detection, + firebaseConfigState.selectedProject, + getFirebaseConfigNeeds, + startFirebaseConfigDownload, + ], + ); + + const handleFirebaseConfigCreateApp = useCallback( + 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; + } + + const normalizedIdentifier = identifier.trim(); + setFirebaseConfigState((prev) => ({ + ...prev, + selectedProject, + creatingAppPlatform: platform, + error: null, + })); + setActiveLeafTaskId('firebase_config_creating_app'); + + try { + if (platform === 'android') { + const androidApp = await firebaseConfigDownloader.createAndroidApp( + selectedProject.projectId, + { + packageName: normalizedIdentifier, + displayName, + }, + ); + + setFirebaseConfigState((prev) => ({ + ...prev, + androidApps: [androidApp], + selectedAndroidApp: androidApp, + })); + + 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; + } + + 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 iosApps = await firebaseConfigDownloader.listIosApps(selectedProject.projectId); + const existingIosApp = firebaseConfigDownloader.findAppByBundleId( + iosApps, + normalizedIdentifier, + ); + const iosApp = + existingIosApp || + (await firebaseConfigDownloader.createIosApp(selectedProject.projectId, { + bundleId: normalizedIdentifier, + displayName, + teamId: context?.ios.teamId, + })); + + setFirebaseConfigState((prev) => ({ + ...prev, + iosApps: existingIosApp ? iosApps : [...iosApps, iosApp], + selectedIosApp: iosApp, + })); + + 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'); + } + }, + [ + context, + firebaseConfigDownloader, + firebaseConfigState.detection, + firebaseConfigState.projectType, + firebaseConfigState.selectedAndroidApp, + firebaseConfigState.selectedProject, + getFirebaseConfigNeeds, + projectPath, + 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)) { + const mismatch = getFirebaseConfigIdentifierMismatch(detection, context); + if (!mismatch.ios && !mismatch.android) { + 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, phase, projectPath]); + + useEffect(() => { + if ( + phase !== 'task' || + activeTaskId !== 'firebase_config_files' || + activeLeafTaskId !== 'firebase_config_authenticating' + ) { + return; + } + + let cancelled = false; + + // 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((url) => { + if (cancelled) { + return; + } + setFirebaseConfigState((prev) => ({ ...prev, authUrl: url })); + void openBrowser(url); + }); + 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; + } + + 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, + isCreateIosAction ? context?.ios.appName : undefined, + targetProject, + ); + 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; + firebaseConfigDownloader.cancelAuthentication( + 'Firebase configuration authentication cancelled', + ); + }; + }, [ + activeLeafTaskId, + activeTaskId, + context?.ios.appName, + context?.ios.bundleId, + context?.firebase.actualAndroidPackageName, + firebaseConfigDownloader, + firebaseConfigState.detection, + firebaseConfigState.postAuthAction, + handleFirebaseConfigCreateApp, + 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 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.', + errorNextLeafTaskId: 'firebase_service_account_detecting', + })); + setActiveLeafTaskId('firebase_service_account_error'); + return; + } + + setFirebaseServiceAccountState((prev) => ({ + ...prev, + projectId: projectIdResolution.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 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( + (config) => config.channel_type === 'CHANNEL_TYPE_APP_PUSH', + ); + + if (cancelled) { + return; + } + + 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, + })); + 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, + firebaseServiceAccountState.projectId, + 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, + context?.config.project.id, + 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, + context?.config.project.id, + 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, + projectId: nseContext.projectId, + }; + + const result = await createExtensionFiles(extensionContext); + if (cancelled) { + return; + } + + setNotificationExtensionState((prev) => ({ + ...prev, + extensionResult: result, + warnings: result.warnings, + })); + 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, + ]); + + 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, + ]); + + 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 ; + } + + if (phase === 'config_missing') { + return ; + } + + if (!context) { + return ; + } + + if (phase === 'status' || !activeTaskId || !activeLeafTaskId) { + return ( + + ); + } + + switch (activeTaskId) { + 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 ( + + + + {activeLeafTaskId === 'firebase_config_detecting' && } + + {activeLeafTaskId === 'firebase_config_status' && detection && ( + { + 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()} + /> + )} + + {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.createProject)} + onRetry={() => { + setFirebaseConfigState((prev) => ({ + ...prev, + authUrl: null, + projects: [], + selectedProject: null, + androidApps: [], + iosApps: [], + selectedAndroidApp: null, + noAppsContext: null, + error: null, + })); + setActiveLeafTaskId('firebase_config_authenticating'); + }} + onCancel={() => handleTaskBackToStatus()} + /> + )} + + {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()} + /> + )} + + {activeLeafTaskId === 'apns_input' && ( + { + setApnsState((prev) => ({ + ...(prev || createInitialApnsState(context)), + acquisition: result, + })); + + 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 ? ( + { + 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, + registeredWithFirebase: true, + keyId: registrationContext.pushKey?.apnsKeyId, + teamId: registrationContext.pushKey?.teamId, + }, + }); + }} + onCancel={() => handleTaskBackToStatus()} + /> + ) : ( + + ✗ 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, + }, + }); + }} + /> + )} + + ); + } + + case 'firebase_service_account': + return ( + + + + {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, + xcodeprojPath: result.agentContext.projectPath, + projectId: context.config.project.id || '', + 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 '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 ( + + + + {activeLeafTaskId === 'nse_prepare_context' && ( + + + + + + Preparing extension setup context... + + + )} + + {activeLeafTaskId === 'nse_create_files' && ( + + + + + + Creating Notification Service Extension files... + + + )} + + {activeLeafTaskId === 'nse_xcode_target' && nseContext && ( + + )} + + {activeLeafTaskId === 'nse_build_settings' && nseContext && ( + + )} + + {activeLeafTaskId === 'nse_dependencies' && ( + + )} + + {activeLeafTaskId === 'nse_verification' && nseContext && ( + + )} + + {activeLeafTaskId === 'nse_complete' && ( + + )} + + ); + } + + case 'install_skill': { + const failedItems: ActionItem[] = [ + { id: 'retry', label: 'Retry SDK installation', action: 'continue' }, + { id: 'cancel', label: 'Cancel', action: 'cancel' }, + ]; + const installSkillMessages = getTaskMessages(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); + installSkillRunStartedRef.current = false; + setActiveLeafTaskId('install_skill_running'); + }} + onCancel={() => + handleTaskBackToStatus( + 'SDK installation was not completed. Continue to SDK installation to finish /install.', + ) + } + /> + + + )} + + ); + } + + default: + 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/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/ProjectSelector.tsx b/src/ui/components/ProjectSelector.tsx index 1fa2a1b..60be9bf 100644 --- a/src/ui/components/ProjectSelector.tsx +++ b/src/ui/components/ProjectSelector.tsx @@ -3,16 +3,24 @@ 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' }); +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[]; onSelect: (project: Project, org: Organization) => void; @@ -35,6 +43,63 @@ 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)); +} + +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 }, + 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 +107,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; + // Calculate visible window for scrolling based on terminal height. + const maxVisible = getProjectSelectorMaxVisible(); + 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 +138,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 +198,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 +229,10 @@ export const ProjectSelector: React.FC = ({ {workspacePath} + + Search: + {searchQuery || '(type to search)'} + {startIndex > 0 && ( @@ -151,7 +261,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/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/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__/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..9bd0f01 --- /dev/null +++ b/src/ui/components/__tests__/install-preparation-flow.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, test } from 'bun:test'; +import type { + ApnsStatus, + FirebaseStatus, + IosStatus, + PreparationContext, +} from '@/commands/skill/preparation'; +import type { SenderConfig } from '@/lib/api'; +import type { ProjectType } from '@/lib/config'; +import { getStatusLayoutPolicy, validateSenderConfigProjectId } 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, + }; +} + +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( + { + 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', + 'firebase_service_account', + 'apns_key_for_firebase', + 'ios_entitlements', + 'notification_service_extension', + 'install_skill', + ]); + }); + + test('returns service account before APNS in required task 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('firebase_service_account'); + }); + + test('moves to APNS after service account is complete', () => { + const context = createContext( + { + configured: false, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + needed: true, + }, + { + needed: true, + entitlementsConfigured: false, + nseConfigured: false, + }, + { + needed: true, + keyId: 'ABC1234567', + registeredWithFirebase: false, + }, + false, + ['APNS Key for Firebase'], + ); + + expect(getNextIncompleteTaskId(context)).toBe('apns_key_for_firebase'); + }); + + 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); + }); + + 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, + androidConfigured: true, + iosConfigured: true, + senderConfigConfigured: true, + needed: true, + }, + { + needed: true, + entitlementsConfigured: true, + nseConfigured: true, + }, + { + needed: true, + registeredWithFirebase: true, + }, + true, + [], + ); + + expect(getNextIncompleteTaskId(context)).toBe('install_skill'); + expect(isTaskCompleted(context, 'install_skill')).toBe(false); + }); + + test('returns null after SDK installation runtime task 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, 'install_skill')).toBe(false); + 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', () => { + 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', + 'firebase_service_account', + 'apns_key_for_firebase', + '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', () => { + 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'); + }); +}); + +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/__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-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; +} diff --git a/src/ui/components/install-preparation-tasks.ts b/src/ui/components/install-preparation-tasks.ts new file mode 100644 index 0000000..3144af9 --- /dev/null +++ b/src/ui/components/install-preparation-tasks.ts @@ -0,0 +1,118 @@ +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' + | 'install_skill'; + +export type RuntimeTaskState = 'idle' | 'running' | 'failed' | 'complete'; +export type RuntimeTaskStateMap = Partial>; +export interface InstallTaskSelectionOptions { + includeRuntimeTasks?: boolean; +} + +const INSTALL_TASK_ORDER: InstallTaskId[] = [ + 'firebase_config_files', + 'firebase_service_account', + 'apns_key_for_firebase', + 'ios_entitlements', + 'notification_service_extension', + 'install_skill', +]; + +const RUNTIME_TASK_IDS: InstallTaskId[] = ['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', + install_skill: 'SDK Installation', +}; + +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; + case 'install_skill': + return true; + default: + return false; + } +} + +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 = {}, +): 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; + 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; + case 'notification_service_extension': + return context.ios.nseConfigured; + default: + return false; + } +} + +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, options); + return tasks.find((taskId) => !isTaskCompleted(context, taskId, runtimeTaskState)) ?? 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..6f6ef88 --- /dev/null +++ b/src/ui/components/ios-setup/IosEntitlementsTask.tsx @@ -0,0 +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 = ({ + appleAuthContext, + onComplete, +}) => { + return ; +}; diff --git a/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx b/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx new file mode 100644 index 0000000..dcc8da3 --- /dev/null +++ b/src/ui/components/notification-extension-setup/NotificationExtensionTasks.tsx @@ -0,0 +1,238 @@ +/** + * Notification Extension setup task components. + */ + +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 { StatusMessage } from '../StatusMessage'; + +export interface NotificationExtensionSetupContext { + bundleId: string; + 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; + xcodeResult: PbxprojModificationResult | null; +}> = ({ extensionName, extensionBundleId, extensionResult, appGroupId, xcodeResult }) => ( + + + + Xcode Target Configuration + + + + {extensionResult && extensionResult.createdFiles.length > 0 && ( + + + {extensionResult.createdFiles.map((filePath) => ( + + • {filePath} + + ))} + + )} + + {!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} + + )} + +); + +export const NotificationExtensionBuildSettingsTask: React.FC<{ + extensionName: string; + entitlementsPath: string; +}> = ({ extensionName, entitlementsPath }) => ( + + + + Build Settings + + + + ✓ Extension build settings applied + Target: {extensionName} + Entitlements: {entitlementsPath} + + +); + +export const NotificationExtensionDependenciesTask: React.FC<{ + extensionName: string; + podfileResult: PodfileModificationResult | null; +}> = ({ extensionName, podfileResult }) => ( + + + + Dependency Configuration + + + + {!podfileResult ? ( + + + + + Configuring Clix dependency for {extensionName}... + + ) : ( + + {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; + 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 ( + + + + Verification + + + + + + {effectiveChecks.filesComplete ? '✓' : '✗'} Extension files + + + {effectiveChecks.xcodeTargetConfigured ? '✓' : '✗'} Xcode NSE target + + + {effectiveChecks.buildSettingsConfigured ? '✓' : '✗'} NSE build settings + + + {effectiveChecks.podDependencyConfigured ? '✓' : '✗'} Clix dependency + + + {effectiveChecks.notificationServiceConfigured ? '✓' : '✗'} NotificationService.swift + + + + {effectiveChecks.missingReasons.length > 0 && ( + + + Remaining issues: + + {effectiveChecks.missingReasons.map((reason) => ( + + • {reason} + + ))} + + )} + + {extensionResult?.extensionDir ? ( + + Directory: {extensionResult.extensionDir} + + ) : null} + + ); +}; + +export const NotificationExtensionCompleteTask: React.FC<{ + error: string | null; + extensionResult: ExtensionGeneratorResult | null; + warnings?: string[]; +}> = ({ error, extensionResult, warnings = [] }) => ( + + {error ? ( + + ) : ( + + + + ✓ Notification Service Extension setup completed + + + {extensionResult?.createdFiles.length ? ( + + {extensionResult.createdFiles.map((filePath) => ( + + • {filePath} + + ))} + + ) : null} + {warnings.length > 0 ? ( + + Warnings: + {warnings.map((warning) => ( + + • {warning} + + ))} + + ) : null} + + )} + +); diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/push-setup/PushSetupTasks.tsx similarity index 55% rename from src/ui/components/PushSetupWizard.tsx rename to src/ui/components/push-setup/PushSetupTasks.tsx index 8db8786..7a6b150 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'; @@ -13,7 +13,8 @@ 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 { analyzeIosProject } from '@/lib/ios'; +import { PROJECT_CONFIG_DIR } from '@/lib/config/project-config-schema'; +import { analyzeIosProject, type UserAuthContext } from '@/lib/ios'; import { APNS_KEY_CREATION_STEPS, FIREBASE_UPLOAD_STEPS, @@ -21,36 +22,75 @@ 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 { useCancelInput } from '@/ui/hooks'; +import { formatTerminalHyperlink } from '@/ui/utils/terminalHyperlink'; +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; + /** Apple auth context for reuse by downstream tasks (e.g., portal sync). */ + authContext?: UserAuthContext; +} + +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, - 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' }, ]; @@ -157,11 +361,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(); @@ -191,9 +392,6 @@ function KeySourcePhase({ ); } -/** - * Apple guide phase component. - */ function AppleGuidePhase({ onContinue, onCancel, @@ -242,7 +440,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 @@ -251,25 +451,35 @@ 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}`) - .sort(); + 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].sort(); } catch { return []; } } -/** - * P8 input phase component. - */ function P8InputPhase({ suggestedKeyId, suggestedTeamId, @@ -289,42 +499,68 @@ function P8InputPhase({ const [error, setError] = useState(null); const [extractedKeyId, setExtractedKeyId] = useState(null); const [prefilledTeamId] = useState(suggestedTeamId || null); + const p8PathRef = useRef(''); - // 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); - } + // 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'); 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); @@ -334,24 +570,28 @@ function P8InputPhase({ }, []); const handleP8PathSubmit = useCallback(() => { - if (!p8Path.trim()) { + const currentPath = p8PathRef.current; + if (!currentPath.trim()) { setError('P8 file path is required'); return; } - const result = validateP8File(p8Path.trim()); + 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) { setExtractedKeyId(result.suggestedKeyId); setKeyId(result.suggestedKeyId); } setStage('key_id'); - }, [p8Path]); + }, []); const handleKeyIdSubmit = useCallback(() => { const keyIdError = getKeyIdError(keyId.trim()); @@ -359,6 +599,7 @@ function P8InputPhase({ setError(keyIdError); return; } + setError(null); setStage('team_id'); }, [keyId]); @@ -369,15 +610,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' }, ]; @@ -405,7 +646,8 @@ function P8InputPhase({ <> - Found {foundFiles.length} P8 file(s) in current directory: + Found {foundFiles.length} P8 file(s) in ./ or ./ + {PROJECT_CONFIG_DIR}: @@ -419,17 +661,23 @@ 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 - {'> '} - + {'>'} + {p8Path ? ( + + {p8Path} + + + ) : ( + + D + {'rag .p8 file here, or enter path manually'} + + )} )} @@ -450,7 +698,7 @@ function P8InputPhase({ )} - {'> '} + {'>'} )} - {'> '} + {'>'} - {!prefilledTeamId && ( - - Press 't' to open Team ID page in browser - - )} )} @@ -511,75 +754,215 @@ 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'} ); } -/** - * Firebase authentication phase component. - */ -function FirebaseAuthPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { - useCancelInput(onCancel); - - return ( - - - Firebase Authentication - - - - - - Authenticating with Firebase... - - - A browser window will open for authentication - - - Esc to cancel - - - ); -} +type ApnsTaskPhase = 'key_source' | 'apple_login' | 'apple_guide' | 'p8_input' | 'error'; -/** - * Firebase project selection phase component. - */ -function FirebaseProjectsPhase({ - projects, - onSelect, - onCancel, -}: { - projects: FirebaseProject[]; - onSelect: (project: FirebaseProject) => void; +export const ApnsKeyAcquisitionTask: React.FC<{ + projectPath: string; + suggestedTeamId?: string | null; + onComplete: (result: ApnsKeyAcquisitionResult) => void; onCancel: () => void; -}): React.ReactElement { - const items = projects.map((p) => ({ - label: p.displayName || p.projectId, - value: p.projectId, - })); - - const handleSelect = useCallback( - (item: { label: string; value: string }) => { - const project = projects.find((p) => p.projectId === item.value); - if (project) { - onSelect(project); - } - }, - [onSelect, projects], - ); +}> = ({ projectPath, suggestedTeamId, onComplete, onCancel }) => { + const [phase, setPhase] = useState('key_source'); + const [error, setError] = useState(null); - useCancelInput(onCancel); + const handleAppleLoginSuccess = useCallback( + (result: { + authContext: UserAuthContext; + pushKey: { apnsKeyId: string; apnsKeyP8: string; teamId: string; teamName?: string }; + }) => { + const p8FileName = `AuthKey_${result.pushKey.apnsKeyId}.p8`; + 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, + }); + savedPath = projectP8Path; + } catch { + try { + 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 under ${PROJECT_CONFIG_DIR}. 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, + authContext: result.authContext, + }); + }, + [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')} + onCreateKey={() => 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, + 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 + + {reopenLink ? ( + + If browser was closed, reopen this URL: + {reopenLink} + + ) : null} + + Esc/Ctrl+C to cancel + + + {authUrl ? ( + + Direct URL: + {authUrl} + + ) : null} + + ); +} + +function FirebaseProjectsPhase({ + 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.projectId, + })); + + const handleSelect = useCallback( + (item: { label: string; value: string }) => { + const project = projects.find((candidate) => candidate.projectId === item.value); + if (project) { + onSelect(project); + } + }, + [onSelect, projects], + ); + + useCancelInput(onCancel); return ( 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 [authUrl, setAuthUrl] = useState(null); + const downloaderRef = useRef(null); + const cancelAuthentication = useCallback(() => { + downloaderRef.current?.cancelAuthentication('Firebase project selection cancelled'); + onCancel(); + }, [onCancel]); + + useEffect(() => { + if (phase !== 'authenticating') { + return; + } + + let cancelled = false; + + const authenticateAndSelect = async () => { + try { + setAuthUrl(null); + downloaderRef.current = downloaderRef.current ?? new FirebaseDownloader(); + const downloader = downloaderRef.current; + 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'); + } + + 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; + downloaderRef.current?.cancelAuthentication('Firebase project selection cancelled'); + }; + }, [onComplete, phase, preferredProjectId]); + + if (phase === 'authenticating') { + return ; + } + + if (phase === 'select_project') { + return ( + onComplete(project)} + onCancel={cancelAuthentication} + /> + ); + } + + return ( + { + setError(null); + setAuthUrl(null); + setPhase('authenticating'); + }} + onCancel={cancelAuthentication} + /> + ); +}; + function FirebaseUploadPhase({ context, selectedProject, @@ -620,7 +1129,6 @@ function FirebaseUploadPhase({ }): React.ReactElement { const [browserOpened, setBrowserOpened] = useState(false); - // Compute project label with fallback const projectLabel = selectedProject?.displayName || selectedProject?.projectId || @@ -629,16 +1137,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) { @@ -699,9 +1205,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} ))} @@ -716,534 +1222,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 ; - } }; 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/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}`; +} diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index 94dc7e2..2900080 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -31,8 +31,9 @@ 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).toContain('install-mcp'); + expect(result.stdout).not.toContain('debug '); + 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 '); @@ -44,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 () => { @@ -53,7 +53,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 () => { @@ -130,11 +131,10 @@ describe('CLI Help Output Formatting', () => { const result = await rig.run(['--help']); // 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).toContain('Autonomous SDK installation'); + 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'); }); @@ -143,6 +143,6 @@ describe('CLI Help Output Formatting', () => { // install command should be listed expect(result.stdout).toContain('install'); - expect(result.stdout).toContain('Autonomous SDK installation'); + expect(result.stdout).toContain('Install Clix SDK'); }); }); diff --git a/tests/e2e/command-output.test.ts b/tests/e2e/command-output.test.ts index 713957b..20ecd77 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,8 @@ 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).toContain('skills'); + expect(result.stdout).not.toContain('debug '); }); }); @@ -77,12 +58,11 @@ describe('Final Output Format Verification', () => { // 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).toContain('Autonomous SDK installation'); - expect(result.stdout).toContain('Interactive debugging assistant'); + expect(result.stdout).toContain('Install Clix SDK'); + expect(result.stdout).toContain('Check Clix SDK integration status'); }); });