#
tokens: 44244/50000 7/337 files (page 12/14)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 12 of 14. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .axe-version
├── .claude
│   └── agents
│       └── xcodebuild-mcp-qa-tester.md
├── .cursor
│   ├── BUGBOT.md
│   └── environment.json
├── .cursorrules
├── .github
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.yml
│   │   ├── config.yml
│   │   └── feature_request.yml
│   └── workflows
│       ├── ci.yml
│       ├── claude-code-review.yml
│       ├── claude-dispatch.yml
│       ├── claude.yml
│       ├── droid-code-review.yml
│       ├── README.md
│       ├── release.yml
│       └── sentry.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── .vscode
│   ├── extensions.json
│   ├── launch.json
│   ├── mcp.json
│   ├── settings.json
│   └── tasks.json
├── AGENTS.md
├── banner.png
├── build-plugins
│   ├── plugin-discovery.js
│   ├── plugin-discovery.ts
│   └── tsconfig.json
├── CHANGELOG.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── Dockerfile
├── docs
│   ├── ARCHITECTURE.md
│   ├── CODE_QUALITY.md
│   ├── CONTRIBUTING.md
│   ├── ESLINT_TYPE_SAFETY.md
│   ├── MANUAL_TESTING.md
│   ├── NODEJS_2025.md
│   ├── PLUGIN_DEVELOPMENT.md
│   ├── RELEASE_PROCESS.md
│   ├── RELOADEROO_FOR_XCODEBUILDMCP.md
│   ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md
│   ├── RELOADEROO.md
│   ├── session_management_plan.md
│   ├── session-aware-migration-todo.md
│   ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md
│   ├── TESTING.md
│   └── TOOLS.md
├── eslint.config.js
├── example_projects
│   ├── .vscode
│   │   └── launch.json
│   ├── iOS
│   │   ├── .cursor
│   │   │   └── rules
│   │   │       └── errors.mdc
│   │   ├── .vscode
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── MCPTest
│   │   │   ├── Assets.xcassets
│   │   │   │   ├── AccentColor.colorset
│   │   │   │   │   └── Contents.json
│   │   │   │   ├── AppIcon.appiconset
│   │   │   │   │   └── Contents.json
│   │   │   │   └── Contents.json
│   │   │   ├── ContentView.swift
│   │   │   ├── MCPTestApp.swift
│   │   │   └── Preview Content
│   │   │       └── Preview Assets.xcassets
│   │   │           └── Contents.json
│   │   ├── MCPTest.xcodeproj
│   │   │   ├── project.pbxproj
│   │   │   └── xcshareddata
│   │   │       └── xcschemes
│   │   │           └── MCPTest.xcscheme
│   │   └── MCPTestUITests
│   │       └── MCPTestUITests.swift
│   ├── iOS_Calculator
│   │   ├── CalculatorApp
│   │   │   ├── Assets.xcassets
│   │   │   │   ├── AccentColor.colorset
│   │   │   │   │   └── Contents.json
│   │   │   │   ├── AppIcon.appiconset
│   │   │   │   │   └── Contents.json
│   │   │   │   └── Contents.json
│   │   │   ├── CalculatorApp.swift
│   │   │   └── CalculatorApp.xctestplan
│   │   ├── CalculatorApp.xcodeproj
│   │   │   ├── project.pbxproj
│   │   │   └── xcshareddata
│   │   │       └── xcschemes
│   │   │           └── CalculatorApp.xcscheme
│   │   ├── CalculatorApp.xcworkspace
│   │   │   └── contents.xcworkspacedata
│   │   ├── CalculatorAppPackage
│   │   │   ├── .gitignore
│   │   │   ├── Package.swift
│   │   │   ├── Sources
│   │   │   │   └── CalculatorAppFeature
│   │   │   │       ├── BackgroundEffect.swift
│   │   │   │       ├── CalculatorButton.swift
│   │   │   │       ├── CalculatorDisplay.swift
│   │   │   │       ├── CalculatorInputHandler.swift
│   │   │   │       ├── CalculatorService.swift
│   │   │   │       └── ContentView.swift
│   │   │   └── Tests
│   │   │       └── CalculatorAppFeatureTests
│   │   │           └── CalculatorServiceTests.swift
│   │   ├── CalculatorAppTests
│   │   │   └── CalculatorAppTests.swift
│   │   └── Config
│   │       ├── Debug.xcconfig
│   │       ├── Release.xcconfig
│   │       ├── Shared.xcconfig
│   │       └── Tests.xcconfig
│   ├── macOS
│   │   ├── MCPTest
│   │   │   ├── Assets.xcassets
│   │   │   │   ├── AccentColor.colorset
│   │   │   │   │   └── Contents.json
│   │   │   │   ├── AppIcon.appiconset
│   │   │   │   │   └── Contents.json
│   │   │   │   └── Contents.json
│   │   │   ├── ContentView.swift
│   │   │   ├── MCPTest.entitlements
│   │   │   ├── MCPTestApp.swift
│   │   │   └── Preview Content
│   │   │       └── Preview Assets.xcassets
│   │   │           └── Contents.json
│   │   └── MCPTest.xcodeproj
│   │       ├── project.pbxproj
│   │       └── xcshareddata
│   │           └── xcschemes
│   │               └── MCPTest.xcscheme
│   └── spm
│       ├── .gitignore
│       ├── Package.resolved
│       ├── Package.swift
│       ├── Sources
│       │   ├── long-server
│       │   │   └── main.swift
│       │   ├── quick-task
│       │   │   └── main.swift
│       │   ├── spm
│       │   │   └── main.swift
│       │   └── TestLib
│       │       └── TaskManager.swift
│       └── Tests
│           └── TestLibTests
│               └── SimpleTests.swift
├── LICENSE
├── mcp-install-dark.png
├── package-lock.json
├── package.json
├── README.md
├── scripts
│   ├── analysis
│   │   └── tools-analysis.ts
│   ├── bundle-axe.sh
│   ├── check-code-patterns.js
│   ├── release.sh
│   ├── tools-cli.ts
│   └── update-tools-docs.ts
├── server.json
├── smithery.yaml
├── src
│   ├── core
│   │   ├── __tests__
│   │   │   └── resources.test.ts
│   │   ├── dynamic-tools.ts
│   │   ├── plugin-registry.ts
│   │   ├── plugin-types.ts
│   │   └── resources.ts
│   ├── doctor-cli.ts
│   ├── index.ts
│   ├── mcp
│   │   ├── resources
│   │   │   ├── __tests__
│   │   │   │   ├── devices.test.ts
│   │   │   │   ├── doctor.test.ts
│   │   │   │   └── simulators.test.ts
│   │   │   ├── devices.ts
│   │   │   ├── doctor.ts
│   │   │   └── simulators.ts
│   │   └── tools
│   │       ├── device
│   │       │   ├── __tests__
│   │       │   │   ├── build_device.test.ts
│   │       │   │   ├── get_device_app_path.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── install_app_device.test.ts
│   │       │   │   ├── launch_app_device.test.ts
│   │       │   │   ├── list_devices.test.ts
│   │       │   │   ├── re-exports.test.ts
│   │       │   │   ├── stop_app_device.test.ts
│   │       │   │   └── test_device.test.ts
│   │       │   ├── build_device.ts
│   │       │   ├── clean.ts
│   │       │   ├── discover_projs.ts
│   │       │   ├── get_app_bundle_id.ts
│   │       │   ├── get_device_app_path.ts
│   │       │   ├── index.ts
│   │       │   ├── install_app_device.ts
│   │       │   ├── launch_app_device.ts
│   │       │   ├── list_devices.ts
│   │       │   ├── list_schemes.ts
│   │       │   ├── show_build_settings.ts
│   │       │   ├── start_device_log_cap.ts
│   │       │   ├── stop_app_device.ts
│   │       │   ├── stop_device_log_cap.ts
│   │       │   └── test_device.ts
│   │       ├── discovery
│   │       │   ├── __tests__
│   │       │   │   └── discover_tools.test.ts
│   │       │   ├── discover_tools.ts
│   │       │   └── index.ts
│   │       ├── doctor
│   │       │   ├── __tests__
│   │       │   │   ├── doctor.test.ts
│   │       │   │   └── index.test.ts
│   │       │   ├── doctor.ts
│   │       │   ├── index.ts
│   │       │   └── lib
│   │       │       └── doctor.deps.ts
│   │       ├── logging
│   │       │   ├── __tests__
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── start_device_log_cap.test.ts
│   │       │   │   ├── start_sim_log_cap.test.ts
│   │       │   │   ├── stop_device_log_cap.test.ts
│   │       │   │   └── stop_sim_log_cap.test.ts
│   │       │   ├── index.ts
│   │       │   ├── start_device_log_cap.ts
│   │       │   ├── start_sim_log_cap.ts
│   │       │   ├── stop_device_log_cap.ts
│   │       │   └── stop_sim_log_cap.ts
│   │       ├── macos
│   │       │   ├── __tests__
│   │       │   │   ├── build_macos.test.ts
│   │       │   │   ├── build_run_macos.test.ts
│   │       │   │   ├── get_mac_app_path.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── launch_mac_app.test.ts
│   │       │   │   ├── re-exports.test.ts
│   │       │   │   ├── stop_mac_app.test.ts
│   │       │   │   └── test_macos.test.ts
│   │       │   ├── build_macos.ts
│   │       │   ├── build_run_macos.ts
│   │       │   ├── clean.ts
│   │       │   ├── discover_projs.ts
│   │       │   ├── get_mac_app_path.ts
│   │       │   ├── get_mac_bundle_id.ts
│   │       │   ├── index.ts
│   │       │   ├── launch_mac_app.ts
│   │       │   ├── list_schemes.ts
│   │       │   ├── show_build_settings.ts
│   │       │   ├── stop_mac_app.ts
│   │       │   └── test_macos.ts
│   │       ├── project-discovery
│   │       │   ├── __tests__
│   │       │   │   ├── discover_projs.test.ts
│   │       │   │   ├── get_app_bundle_id.test.ts
│   │       │   │   ├── get_mac_bundle_id.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── list_schemes.test.ts
│   │       │   │   └── show_build_settings.test.ts
│   │       │   ├── discover_projs.ts
│   │       │   ├── get_app_bundle_id.ts
│   │       │   ├── get_mac_bundle_id.ts
│   │       │   ├── index.ts
│   │       │   ├── list_schemes.ts
│   │       │   └── show_build_settings.ts
│   │       ├── project-scaffolding
│   │       │   ├── __tests__
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── scaffold_ios_project.test.ts
│   │       │   │   └── scaffold_macos_project.test.ts
│   │       │   ├── index.ts
│   │       │   ├── scaffold_ios_project.ts
│   │       │   └── scaffold_macos_project.ts
│   │       ├── session-management
│   │       │   ├── __tests__
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── session_clear_defaults.test.ts
│   │       │   │   ├── session_set_defaults.test.ts
│   │       │   │   └── session_show_defaults.test.ts
│   │       │   ├── index.ts
│   │       │   ├── session_clear_defaults.ts
│   │       │   ├── session_set_defaults.ts
│   │       │   └── session_show_defaults.ts
│   │       ├── simulator
│   │       │   ├── __tests__
│   │       │   │   ├── boot_sim.test.ts
│   │       │   │   ├── build_run_sim.test.ts
│   │       │   │   ├── build_sim.test.ts
│   │       │   │   ├── get_sim_app_path.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── install_app_sim.test.ts
│   │       │   │   ├── launch_app_logs_sim.test.ts
│   │       │   │   ├── launch_app_sim.test.ts
│   │       │   │   ├── list_sims.test.ts
│   │       │   │   ├── open_sim.test.ts
│   │       │   │   ├── record_sim_video.test.ts
│   │       │   │   ├── screenshot.test.ts
│   │       │   │   ├── stop_app_sim.test.ts
│   │       │   │   └── test_sim.test.ts
│   │       │   ├── boot_sim.ts
│   │       │   ├── build_run_sim.ts
│   │       │   ├── build_sim.ts
│   │       │   ├── clean.ts
│   │       │   ├── describe_ui.ts
│   │       │   ├── discover_projs.ts
│   │       │   ├── get_app_bundle_id.ts
│   │       │   ├── get_sim_app_path.ts
│   │       │   ├── index.ts
│   │       │   ├── install_app_sim.ts
│   │       │   ├── launch_app_logs_sim.ts
│   │       │   ├── launch_app_sim.ts
│   │       │   ├── list_schemes.ts
│   │       │   ├── list_sims.ts
│   │       │   ├── open_sim.ts
│   │       │   ├── record_sim_video.ts
│   │       │   ├── screenshot.ts
│   │       │   ├── show_build_settings.ts
│   │       │   ├── stop_app_sim.ts
│   │       │   └── test_sim.ts
│   │       ├── simulator-management
│   │       │   ├── __tests__
│   │       │   │   ├── erase_sims.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── reset_sim_location.test.ts
│   │       │   │   ├── set_sim_appearance.test.ts
│   │       │   │   ├── set_sim_location.test.ts
│   │       │   │   └── sim_statusbar.test.ts
│   │       │   ├── boot_sim.ts
│   │       │   ├── erase_sims.ts
│   │       │   ├── index.ts
│   │       │   ├── list_sims.ts
│   │       │   ├── open_sim.ts
│   │       │   ├── reset_sim_location.ts
│   │       │   ├── set_sim_appearance.ts
│   │       │   ├── set_sim_location.ts
│   │       │   └── sim_statusbar.ts
│   │       ├── swift-package
│   │       │   ├── __tests__
│   │       │   │   ├── active-processes.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── swift_package_build.test.ts
│   │       │   │   ├── swift_package_clean.test.ts
│   │       │   │   ├── swift_package_list.test.ts
│   │       │   │   ├── swift_package_run.test.ts
│   │       │   │   ├── swift_package_stop.test.ts
│   │       │   │   └── swift_package_test.test.ts
│   │       │   ├── active-processes.ts
│   │       │   ├── index.ts
│   │       │   ├── swift_package_build.ts
│   │       │   ├── swift_package_clean.ts
│   │       │   ├── swift_package_list.ts
│   │       │   ├── swift_package_run.ts
│   │       │   ├── swift_package_stop.ts
│   │       │   └── swift_package_test.ts
│   │       ├── ui-testing
│   │       │   ├── __tests__
│   │       │   │   ├── button.test.ts
│   │       │   │   ├── describe_ui.test.ts
│   │       │   │   ├── gesture.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── key_press.test.ts
│   │       │   │   ├── key_sequence.test.ts
│   │       │   │   ├── long_press.test.ts
│   │       │   │   ├── screenshot.test.ts
│   │       │   │   ├── swipe.test.ts
│   │       │   │   ├── tap.test.ts
│   │       │   │   ├── touch.test.ts
│   │       │   │   └── type_text.test.ts
│   │       │   ├── button.ts
│   │       │   ├── describe_ui.ts
│   │       │   ├── gesture.ts
│   │       │   ├── index.ts
│   │       │   ├── key_press.ts
│   │       │   ├── key_sequence.ts
│   │       │   ├── long_press.ts
│   │       │   ├── screenshot.ts
│   │       │   ├── swipe.ts
│   │       │   ├── tap.ts
│   │       │   ├── touch.ts
│   │       │   └── type_text.ts
│   │       └── utilities
│   │           ├── __tests__
│   │           │   ├── clean.test.ts
│   │           │   └── index.test.ts
│   │           ├── clean.ts
│   │           └── index.ts
│   ├── server
│   │   └── server.ts
│   ├── test-utils
│   │   └── mock-executors.ts
│   ├── types
│   │   └── common.ts
│   └── utils
│       ├── __tests__
│       │   ├── build-utils.test.ts
│       │   ├── environment.test.ts
│       │   ├── session-aware-tool-factory.test.ts
│       │   ├── session-store.test.ts
│       │   ├── simulator-utils.test.ts
│       │   ├── test-runner-env-integration.test.ts
│       │   └── typed-tool-factory.test.ts
│       ├── axe
│       │   └── index.ts
│       ├── axe-helpers.ts
│       ├── build
│       │   └── index.ts
│       ├── build-utils.ts
│       ├── capabilities.ts
│       ├── command.ts
│       ├── CommandExecutor.ts
│       ├── environment.ts
│       ├── errors.ts
│       ├── execution
│       │   └── index.ts
│       ├── FileSystemExecutor.ts
│       ├── log_capture.ts
│       ├── log-capture
│       │   └── index.ts
│       ├── logger.ts
│       ├── logging
│       │   └── index.ts
│       ├── plugin-registry
│       │   └── index.ts
│       ├── responses
│       │   └── index.ts
│       ├── schema-helpers.ts
│       ├── sentry.ts
│       ├── session-store.ts
│       ├── simulator-utils.ts
│       ├── template
│       │   └── index.ts
│       ├── template-manager.ts
│       ├── test
│       │   └── index.ts
│       ├── test-common.ts
│       ├── tool-registry.ts
│       ├── typed-tool-factory.ts
│       ├── validation
│       │   └── index.ts
│       ├── validation.ts
│       ├── version
│       │   └── index.ts
│       ├── video_capture.ts
│       ├── video-capture
│       │   └── index.ts
│       ├── xcode.ts
│       ├── xcodemake
│       │   └── index.ts
│       └── xcodemake.ts
├── tsconfig.json
├── tsconfig.test.json
├── tsup.config.ts
└── vitest.config.ts
```

# Files

--------------------------------------------------------------------------------
/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Utilities Plugin: Scaffold iOS Project
  3 |  *
  4 |  * Scaffold a new iOS project from templates.
  5 |  */
  6 | 
  7 | import { z } from 'zod';
  8 | import { join, dirname, basename } from 'path';
  9 | import { log } from '../../../utils/logging/index.ts';
 10 | import { ValidationError } from '../../../utils/responses/index.ts';
 11 | import { TemplateManager } from '../../../utils/template/index.ts';
 12 | import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts';
 13 | import {
 14 |   getDefaultCommandExecutor,
 15 |   getDefaultFileSystemExecutor,
 16 | } from '../../../utils/execution/index.ts';
 17 | import { ToolResponse } from '../../../types/common.ts';
 18 | 
 19 | // Common base schema for both iOS and macOS
 20 | const BaseScaffoldSchema = z.object({
 21 |   projectName: z.string().min(1).describe('Name of the new project'),
 22 |   outputPath: z.string().describe('Path where the project should be created'),
 23 |   bundleIdentifier: z
 24 |     .string()
 25 |     .optional()
 26 |     .describe(
 27 |       'Bundle identifier (e.g., com.example.myapp). If not provided, will use com.example.projectname',
 28 |     ),
 29 |   displayName: z
 30 |     .string()
 31 |     .optional()
 32 |     .describe(
 33 |       'App display name (shown on home screen/dock). If not provided, will use projectName',
 34 |     ),
 35 |   marketingVersion: z
 36 |     .string()
 37 |     .optional()
 38 |     .describe('Marketing version (e.g., 1.0, 2.1.3). If not provided, will use 1.0'),
 39 |   currentProjectVersion: z
 40 |     .string()
 41 |     .optional()
 42 |     .describe('Build number (e.g., 1, 42, 100). If not provided, will use 1'),
 43 |   customizeNames: z
 44 |     .boolean()
 45 |     .default(true)
 46 |     .describe('Whether to customize project names and identifiers. Default is true.'),
 47 | });
 48 | 
 49 | // iOS-specific schema
 50 | const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({
 51 |   deploymentTarget: z
 52 |     .string()
 53 |     .optional()
 54 |     .describe('iOS deployment target (e.g., 18.4, 17.0). If not provided, will use 18.4'),
 55 |   targetedDeviceFamily: z
 56 |     .array(z.enum(['iphone', 'ipad', 'universal']))
 57 |     .optional()
 58 |     .describe('Targeted device families'),
 59 |   supportedOrientations: z
 60 |     .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down']))
 61 |     .optional()
 62 |     .describe('Supported orientations for iPhone'),
 63 |   supportedOrientationsIpad: z
 64 |     .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down']))
 65 |     .optional()
 66 |     .describe('Supported orientations for iPad'),
 67 | });
 68 | 
 69 | /**
 70 |  * Convert orientation enum to iOS constant
 71 |  */
 72 | function orientationToIOSConstant(orientation: string): string {
 73 |   switch (orientation) {
 74 |     case 'Portrait':
 75 |       return 'UIInterfaceOrientationPortrait';
 76 |     case 'PortraitUpsideDown':
 77 |       return 'UIInterfaceOrientationPortraitUpsideDown';
 78 |     case 'LandscapeLeft':
 79 |       return 'UIInterfaceOrientationLandscapeLeft';
 80 |     case 'LandscapeRight':
 81 |       return 'UIInterfaceOrientationLandscapeRight';
 82 |     default:
 83 |       return orientation;
 84 |   }
 85 | }
 86 | 
 87 | /**
 88 |  * Convert device family enum to numeric value
 89 |  */
 90 | function deviceFamilyToNumeric(family: string): string {
 91 |   switch (family) {
 92 |     case 'iPhone':
 93 |       return '1';
 94 |     case 'iPad':
 95 |       return '2';
 96 |     case 'iPhone+iPad':
 97 |       return '1,2';
 98 |     default:
 99 |       return '1,2';
100 |   }
101 | }
102 | 
103 | /**
104 |  * Update Package.swift file with deployment target
105 |  */
106 | function updatePackageSwiftFile(content: string, params: Record<string, unknown>): string {
107 |   let result = content;
108 | 
109 |   const projectName = params.projectName as string;
110 |   const platform = params.platform as string;
111 |   const deploymentTarget = params.deploymentTarget as string | undefined;
112 | 
113 |   // Update ALL target name references in Package.swift
114 |   const featureName = `${projectName}Feature`;
115 |   const testName = `${projectName}FeatureTests`;
116 | 
117 |   // Replace ALL occurrences of MyProjectFeatureTests first (more specific)
118 |   result = result.replace(/MyProjectFeatureTests/g, testName);
119 |   // Then replace ALL occurrences of MyProjectFeature (less specific, so comes after)
120 |   result = result.replace(/MyProjectFeature/g, featureName);
121 | 
122 |   // Update deployment targets based on platform
123 |   if (platform === 'iOS') {
124 |     if (deploymentTarget) {
125 |       // Extract major version (e.g., "17.0" -> "17")
126 |       const majorVersion = deploymentTarget.split('.')[0];
127 |       result = result.replace(/\.iOS\(\.v\d+\)/, `.iOS(.v${majorVersion})`);
128 |     }
129 |   }
130 | 
131 |   return result;
132 | }
133 | 
134 | /**
135 |  * Update XCConfig file with scaffold parameters
136 |  */
137 | function updateXCConfigFile(content: string, params: Record<string, unknown>): string {
138 |   let result = content;
139 | 
140 |   const projectName = params.projectName as string;
141 |   const displayName = params.displayName as string | undefined;
142 |   const bundleIdentifier = params.bundleIdentifier as string | undefined;
143 |   const marketingVersion = params.marketingVersion as string | undefined;
144 |   const currentProjectVersion = params.currentProjectVersion as string | undefined;
145 |   const platform = params.platform as string;
146 |   const deploymentTarget = params.deploymentTarget as string | undefined;
147 |   const targetedDeviceFamily = params.targetedDeviceFamily as string | undefined;
148 |   const supportedOrientations = params.supportedOrientations as string[] | undefined;
149 |   const supportedOrientationsIpad = params.supportedOrientationsIpad as string[] | undefined;
150 | 
151 |   // Update project identity settings
152 |   result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${projectName}`);
153 |   result = result.replace(
154 |     /PRODUCT_DISPLAY_NAME = .+/g,
155 |     `PRODUCT_DISPLAY_NAME = ${displayName ?? projectName}`,
156 |   );
157 |   result = result.replace(
158 |     /PRODUCT_BUNDLE_IDENTIFIER = .+/g,
159 |     `PRODUCT_BUNDLE_IDENTIFIER = ${bundleIdentifier ?? `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`}`,
160 |   );
161 |   result = result.replace(
162 |     /MARKETING_VERSION = .+/g,
163 |     `MARKETING_VERSION = ${marketingVersion ?? '1.0'}`,
164 |   );
165 |   result = result.replace(
166 |     /CURRENT_PROJECT_VERSION = .+/g,
167 |     `CURRENT_PROJECT_VERSION = ${currentProjectVersion ?? '1'}`,
168 |   );
169 | 
170 |   // Platform-specific updates
171 |   if (platform === 'iOS') {
172 |     // iOS deployment target
173 |     if (deploymentTarget) {
174 |       result = result.replace(
175 |         /IPHONEOS_DEPLOYMENT_TARGET = .+/g,
176 |         `IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget}`,
177 |       );
178 |     }
179 | 
180 |     // Device family
181 |     if (targetedDeviceFamily) {
182 |       const deviceFamilyValue = deviceFamilyToNumeric(targetedDeviceFamily);
183 |       result = result.replace(
184 |         /TARGETED_DEVICE_FAMILY = .+/g,
185 |         `TARGETED_DEVICE_FAMILY = ${deviceFamilyValue}`,
186 |       );
187 |     }
188 | 
189 |     // iPhone orientations
190 |     if (supportedOrientations && supportedOrientations.length > 0) {
191 |       // Filter out any empty strings and validate
192 |       const validOrientations = supportedOrientations.filter((o: string) => o && o.trim() !== '');
193 |       if (validOrientations.length > 0) {
194 |         const orientations = validOrientations.map(orientationToIOSConstant).join(' ');
195 |         result = result.replace(
196 |           /INFOPLIST_KEY_UISupportedInterfaceOrientations = .+/g,
197 |           `INFOPLIST_KEY_UISupportedInterfaceOrientations = ${orientations}`,
198 |         );
199 |       }
200 |     }
201 | 
202 |     // iPad orientations
203 |     if (supportedOrientationsIpad && supportedOrientationsIpad.length > 0) {
204 |       // Filter out any empty strings and validate
205 |       const validOrientations = supportedOrientationsIpad.filter(
206 |         (o: string) => o && o.trim() !== '',
207 |       );
208 |       if (validOrientations.length > 0) {
209 |         const orientations = validOrientations.map(orientationToIOSConstant).join(' ');
210 |         result = result.replace(
211 |           /INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = .+/g,
212 |           `INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = ${orientations}`,
213 |         );
214 |       }
215 |     }
216 | 
217 |     // Update entitlements path for iOS
218 |     result = result.replace(
219 |       /CODE_SIGN_ENTITLEMENTS = .+/g,
220 |       `CODE_SIGN_ENTITLEMENTS = Config/${projectName}.entitlements`,
221 |     );
222 |   }
223 | 
224 |   // Update test bundle identifier and target name
225 |   result = result.replace(/TEST_TARGET_NAME = .+/g, `TEST_TARGET_NAME = ${projectName}`);
226 | 
227 |   // Update comments that reference MyProject in entitlements paths
228 |   result = result.replace(/Config\/MyProject\.entitlements/g, `Config/${projectName}.entitlements`);
229 | 
230 |   return result;
231 | }
232 | 
233 | /**
234 |  * Replace placeholders in a string (for non-XCConfig files)
235 |  */
236 | function replacePlaceholders(
237 |   content: string,
238 |   projectName: string,
239 |   bundleIdentifier: string,
240 | ): string {
241 |   let result = content;
242 | 
243 |   // Replace project name
244 |   result = result.replace(/MyProject/g, projectName);
245 | 
246 |   // Replace bundle identifier - check for both patterns used in templates
247 |   if (bundleIdentifier) {
248 |     result = result.replace(/com\.example\.MyProject/g, bundleIdentifier);
249 |     result = result.replace(/com\.mycompany\.MyProject/g, bundleIdentifier);
250 |   }
251 | 
252 |   return result;
253 | }
254 | 
255 | /**
256 |  * Process a single file, replacing placeholders if it's a text file
257 |  */
258 | async function processFile(
259 |   sourcePath: string,
260 |   destPath: string,
261 |   params: Record<string, unknown>,
262 |   fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
263 | ): Promise<void> {
264 |   const projectName = params.projectName as string;
265 |   const bundleIdentifierParam = params.bundleIdentifier as string | undefined;
266 |   const customizeNames = params.customizeNames as boolean | undefined;
267 | 
268 |   // Determine the destination file path
269 |   let finalDestPath = destPath;
270 |   if (customizeNames) {
271 |     // Replace MyProject in file/directory names
272 |     const fileName = basename(destPath);
273 |     const dirName = dirname(destPath);
274 |     const newFileName = fileName.replace(/MyProject/g, projectName);
275 |     finalDestPath = join(dirName, newFileName);
276 |   }
277 | 
278 |   // Text file extensions that should be processed
279 |   const textExtensions = [
280 |     '.swift',
281 |     '.h',
282 |     '.m',
283 |     '.mm',
284 |     '.cpp',
285 |     '.c',
286 |     '.pbxproj',
287 |     '.plist',
288 |     '.xcscheme',
289 |     '.xctestplan',
290 |     '.xcworkspacedata',
291 |     '.xcconfig',
292 |     '.json',
293 |     '.xml',
294 |     '.entitlements',
295 |     '.storyboard',
296 |     '.xib',
297 |     '.md',
298 |   ];
299 | 
300 |   const ext = sourcePath.toLowerCase();
301 |   const isTextFile = textExtensions.some((textExt) => ext.endsWith(textExt));
302 |   const isXCConfig = sourcePath.endsWith('.xcconfig');
303 |   const isPackageSwift = sourcePath.endsWith('Package.swift');
304 | 
305 |   if (isTextFile && customizeNames) {
306 |     // Read the file content
307 |     const content = await fileSystemExecutor.readFile(sourcePath, 'utf-8');
308 | 
309 |     let processedContent;
310 | 
311 |     if (isXCConfig) {
312 |       // Use special XCConfig processing
313 |       processedContent = updateXCConfigFile(content, params);
314 |     } else if (isPackageSwift) {
315 |       // Use special Package.swift processing
316 |       processedContent = updatePackageSwiftFile(content, params);
317 |     } else {
318 |       // Use standard placeholder replacement
319 |       const bundleIdentifier =
320 |         bundleIdentifierParam ??
321 |         `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
322 |       processedContent = replacePlaceholders(content, projectName, bundleIdentifier);
323 |     }
324 | 
325 |     await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true });
326 |     await fileSystemExecutor.writeFile(finalDestPath, processedContent, 'utf-8');
327 |   } else {
328 |     // Copy binary files as-is
329 |     await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true });
330 |     await fileSystemExecutor.cp(sourcePath, finalDestPath);
331 |   }
332 | }
333 | 
334 | /**
335 |  * Recursively process a directory
336 |  */
337 | async function processDirectory(
338 |   sourceDir: string,
339 |   destDir: string,
340 |   params: Record<string, unknown>,
341 |   fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
342 | ): Promise<void> {
343 |   const entries = await fileSystemExecutor.readdir(sourceDir, { withFileTypes: true });
344 | 
345 |   for (const entry of entries) {
346 |     const entryTyped = entry as { name: string; isDirectory: () => boolean; isFile: () => boolean };
347 |     const sourcePath = join(sourceDir, entryTyped.name);
348 |     let destName = entryTyped.name;
349 | 
350 |     if (params.customizeNames) {
351 |       // Replace MyProject in directory names
352 |       destName = destName.replace(/MyProject/g, params.projectName as string);
353 |     }
354 | 
355 |     const destPath = join(destDir, destName);
356 | 
357 |     if (entryTyped.isDirectory()) {
358 |       // Skip certain directories
359 |       if (entryTyped.name === '.git' || entryTyped.name === 'xcuserdata') {
360 |         continue;
361 |       }
362 |       await fileSystemExecutor.mkdir(destPath, { recursive: true });
363 |       await processDirectory(sourcePath, destPath, params, fileSystemExecutor);
364 |     } else if (entryTyped.isFile()) {
365 |       // Skip certain files
366 |       if (entryTyped.name === '.DS_Store' || entryTyped.name.endsWith('.xcuserstate')) {
367 |         continue;
368 |       }
369 |       await processFile(sourcePath, destPath, params, fileSystemExecutor);
370 |     }
371 |   }
372 | }
373 | 
374 | // Use z.infer for type safety
375 | type ScaffoldIOSProjectParams = z.infer<typeof ScaffoldiOSProjectSchema>;
376 | 
377 | /**
378 |  * Logic function for scaffolding iOS projects
379 |  */
380 | export async function scaffold_ios_projectLogic(
381 |   params: ScaffoldIOSProjectParams,
382 |   commandExecutor: CommandExecutor,
383 |   fileSystemExecutor: FileSystemExecutor,
384 | ): Promise<ToolResponse> {
385 |   try {
386 |     const projectParams = { ...params, platform: 'iOS' };
387 |     const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor);
388 | 
389 |     const response = {
390 |       success: true,
391 |       projectPath,
392 |       platform: 'iOS',
393 |       message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`,
394 |       nextSteps: [
395 |         `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`,
396 |         `Build for simulator: build_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`,
397 |         `Build and run on simulator: build_run_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`,
398 |       ],
399 |     };
400 | 
401 |     return {
402 |       content: [
403 |         {
404 |           type: 'text',
405 |           text: JSON.stringify(response, null, 2),
406 |         },
407 |       ],
408 |     };
409 |   } catch (error) {
410 |     log(
411 |       'error',
412 |       `Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`,
413 |     );
414 | 
415 |     return {
416 |       content: [
417 |         {
418 |           type: 'text',
419 |           text: JSON.stringify(
420 |             {
421 |               success: false,
422 |               error: error instanceof Error ? error.message : 'Unknown error occurred',
423 |             },
424 |             null,
425 |             2,
426 |           ),
427 |         },
428 |       ],
429 |       isError: true,
430 |     };
431 |   }
432 | }
433 | 
434 | /**
435 |  * Scaffold a new iOS or macOS project
436 |  */
437 | async function scaffoldProject(
438 |   params: Record<string, unknown>,
439 |   commandExecutor?: CommandExecutor,
440 |   fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
441 | ): Promise<string> {
442 |   const projectName = params.projectName as string;
443 |   const outputPath = params.outputPath as string;
444 |   const platform = params.platform as 'iOS' | 'macOS';
445 |   const customizeNames = (params.customizeNames as boolean | undefined) ?? true;
446 | 
447 |   log('info', `Scaffolding project: ${projectName} (${platform}) at ${outputPath}`);
448 | 
449 |   // Validate project name
450 |   if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(projectName)) {
451 |     throw new ValidationError(
452 |       'Project name must start with a letter and contain only letters, numbers, and underscores',
453 |     );
454 |   }
455 | 
456 |   // Get template path from TemplateManager
457 |   let templatePath;
458 |   try {
459 |     // Use the default command executor if not provided
460 |     commandExecutor ??= getDefaultCommandExecutor();
461 | 
462 |     templatePath = await TemplateManager.getTemplatePath(
463 |       platform,
464 |       commandExecutor,
465 |       fileSystemExecutor,
466 |     );
467 |   } catch (error) {
468 |     throw new ValidationError(
469 |       `Failed to get template for ${platform}: ${error instanceof Error ? error.message : String(error)}`,
470 |     );
471 |   }
472 | 
473 |   // Use outputPath directly as the destination
474 |   const projectPath = outputPath;
475 | 
476 |   // Check if the output directory already has Xcode project files
477 |   const xcworkspaceExists = fileSystemExecutor.existsSync(
478 |     join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcworkspace`),
479 |   );
480 |   const xcodeprojExists = fileSystemExecutor.existsSync(
481 |     join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcodeproj`),
482 |   );
483 | 
484 |   if (xcworkspaceExists || xcodeprojExists) {
485 |     throw new ValidationError(`Xcode project files already exist in ${projectPath}`);
486 |   }
487 | 
488 |   try {
489 |     // Process the template directly into the output path
490 |     await processDirectory(templatePath, projectPath, params, fileSystemExecutor);
491 | 
492 |     return projectPath;
493 |   } finally {
494 |     // Clean up downloaded template if needed
495 |     await TemplateManager.cleanup(templatePath, fileSystemExecutor);
496 |   }
497 | }
498 | 
499 | export default {
500 |   name: 'scaffold_ios_project',
501 |   description:
502 |     'Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.',
503 |   schema: ScaffoldiOSProjectSchema.shape,
504 |   async handler(args: Record<string, unknown>): Promise<ToolResponse> {
505 |     const params = ScaffoldiOSProjectSchema.parse(args);
506 |     return scaffold_ios_projectLogic(
507 |       params,
508 |       getDefaultCommandExecutor(),
509 |       getDefaultFileSystemExecutor(),
510 |     );
511 |   },
512 | };
513 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/macos/__tests__/test_macos.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Tests for test_macos plugin (unified project/workspace)
  3 |  * Following CLAUDE.md testing standards with literal validation
  4 |  * Using dependency injection for deterministic testing
  5 |  */
  6 | import { describe, it, expect, beforeEach } from 'vitest';
  7 | import { z } from 'zod';
  8 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
  9 | import { sessionStore } from '../../../../utils/session-store.ts';
 10 | import testMacos, { testMacosLogic } from '../test_macos.ts';
 11 | 
 12 | describe('test_macos plugin (unified)', () => {
 13 |   beforeEach(() => {
 14 |     sessionStore.clear();
 15 |   });
 16 | 
 17 |   describe('Export Field Validation (Literal)', () => {
 18 |     it('should have correct name', () => {
 19 |       expect(testMacos.name).toBe('test_macos');
 20 |     });
 21 | 
 22 |     it('should have correct description', () => {
 23 |       expect(testMacos.description).toBe('Runs tests for a macOS target.');
 24 |     });
 25 | 
 26 |     it('should have handler function', () => {
 27 |       expect(typeof testMacos.handler).toBe('function');
 28 |     });
 29 | 
 30 |     it('should validate schema correctly', () => {
 31 |       const schema = z.object(testMacos.schema);
 32 | 
 33 |       expect(schema.safeParse({}).success).toBe(true);
 34 |       expect(
 35 |         schema.safeParse({
 36 |           derivedDataPath: '/path/to/derived-data',
 37 |           extraArgs: ['--arg1', '--arg2'],
 38 |           preferXcodebuild: true,
 39 |           testRunnerEnv: { FOO: 'BAR' },
 40 |         }).success,
 41 |       ).toBe(true);
 42 | 
 43 |       expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false);
 44 |       expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false);
 45 |       expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false);
 46 |       expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false);
 47 | 
 48 |       const schemaKeys = Object.keys(testMacos.schema).sort();
 49 |       expect(schemaKeys).toEqual(
 50 |         ['derivedDataPath', 'extraArgs', 'preferXcodebuild', 'testRunnerEnv'].sort(),
 51 |       );
 52 |     });
 53 |   });
 54 | 
 55 |   describe('Handler Requirements', () => {
 56 |     it('should require scheme before running', async () => {
 57 |       const result = await testMacos.handler({});
 58 | 
 59 |       expect(result.isError).toBe(true);
 60 |       expect(result.content[0].text).toContain('scheme is required');
 61 |     });
 62 | 
 63 |     it('should require project or workspace when scheme default exists', async () => {
 64 |       sessionStore.setDefaults({ scheme: 'MyScheme' });
 65 | 
 66 |       const result = await testMacos.handler({});
 67 | 
 68 |       expect(result.isError).toBe(true);
 69 |       expect(result.content[0].text).toContain('Provide a project or workspace');
 70 |     });
 71 | 
 72 |     it('should reject when both projectPath and workspacePath provided explicitly', async () => {
 73 |       sessionStore.setDefaults({ scheme: 'MyScheme' });
 74 | 
 75 |       const result = await testMacos.handler({
 76 |         projectPath: '/path/to/project.xcodeproj',
 77 |         workspacePath: '/path/to/workspace.xcworkspace',
 78 |       });
 79 | 
 80 |       expect(result.isError).toBe(true);
 81 |       expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
 82 |     });
 83 |   });
 84 | 
 85 |   describe('XOR Parameter Validation', () => {
 86 |     it('should validate that either projectPath or workspacePath is provided', async () => {
 87 |       // Should return error response when neither is provided
 88 |       const result = await testMacos.handler({
 89 |         scheme: 'MyScheme',
 90 |       });
 91 | 
 92 |       expect(result.isError).toBe(true);
 93 |       expect(result.content[0].text).toContain('Provide a project or workspace');
 94 |     });
 95 | 
 96 |     it('should validate that both projectPath and workspacePath cannot be provided', async () => {
 97 |       // Should return error response when both are provided
 98 |       const result = await testMacos.handler({
 99 |         projectPath: '/path/to/project.xcodeproj',
100 |         workspacePath: '/path/to/workspace.xcworkspace',
101 |         scheme: 'MyScheme',
102 |       });
103 | 
104 |       expect(result.isError).toBe(true);
105 |       expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
106 |     });
107 | 
108 |     it('should allow only projectPath', async () => {
109 |       const mockExecutor = createMockExecutor({
110 |         success: true,
111 |         output: 'Test Suite All Tests passed',
112 |       });
113 | 
114 |       const mockFileSystemExecutor = {
115 |         mkdtemp: async () => '/tmp/test-123',
116 |         rm: async () => {},
117 |         tmpdir: () => '/tmp',
118 |         stat: async () => ({ isDirectory: () => true }),
119 |       };
120 | 
121 |       const result = await testMacosLogic(
122 |         {
123 |           projectPath: '/path/to/project.xcodeproj',
124 |           scheme: 'MyScheme',
125 |         },
126 |         mockExecutor,
127 |         mockFileSystemExecutor,
128 |       );
129 | 
130 |       expect(result.content).toBeDefined();
131 |       expect(Array.isArray(result.content)).toBe(true);
132 |       expect(result.isError).toBeUndefined();
133 |     });
134 | 
135 |     it('should allow only workspacePath', async () => {
136 |       const mockExecutor = createMockExecutor({
137 |         success: true,
138 |         output: 'Test Suite All Tests passed',
139 |       });
140 | 
141 |       const mockFileSystemExecutor = {
142 |         mkdtemp: async () => '/tmp/test-123',
143 |         rm: async () => {},
144 |         tmpdir: () => '/tmp',
145 |         stat: async () => ({ isDirectory: () => true }),
146 |       };
147 | 
148 |       const result = await testMacosLogic(
149 |         {
150 |           workspacePath: '/path/to/workspace.xcworkspace',
151 |           scheme: 'MyScheme',
152 |         },
153 |         mockExecutor,
154 |         mockFileSystemExecutor,
155 |       );
156 | 
157 |       expect(result.content).toBeDefined();
158 |       expect(Array.isArray(result.content)).toBe(true);
159 |       expect(result.isError).toBeUndefined();
160 |     });
161 |   });
162 | 
163 |   describe('Handler Behavior (Complete Literal Returns)', () => {
164 |     it('should return successful test response with workspace when xcodebuild succeeds', async () => {
165 |       const mockExecutor = createMockExecutor({
166 |         success: true,
167 |         output: 'Test Suite All Tests passed',
168 |       });
169 | 
170 |       // Mock file system dependencies
171 |       const mockFileSystemExecutor = {
172 |         mkdtemp: async () => '/tmp/test-123',
173 |         rm: async () => {},
174 |         tmpdir: () => '/tmp',
175 |         stat: async () => ({ isDirectory: () => true }),
176 |       };
177 | 
178 |       const result = await testMacosLogic(
179 |         {
180 |           workspacePath: '/path/to/workspace.xcworkspace',
181 |           scheme: 'MyScheme',
182 |           configuration: 'Debug',
183 |         },
184 |         mockExecutor,
185 |         mockFileSystemExecutor,
186 |       );
187 | 
188 |       expect(result.content).toBeDefined();
189 |       expect(Array.isArray(result.content)).toBe(true);
190 |       expect(result.isError).toBeUndefined();
191 |     });
192 | 
193 |     it('should return successful test response with project when xcodebuild succeeds', async () => {
194 |       const mockExecutor = createMockExecutor({
195 |         success: true,
196 |         output: 'Test Suite All Tests passed',
197 |       });
198 | 
199 |       // Mock file system dependencies
200 |       const mockFileSystemExecutor = {
201 |         mkdtemp: async () => '/tmp/test-123',
202 |         rm: async () => {},
203 |         tmpdir: () => '/tmp',
204 |         stat: async () => ({ isDirectory: () => true }),
205 |       };
206 | 
207 |       const result = await testMacosLogic(
208 |         {
209 |           projectPath: '/path/to/project.xcodeproj',
210 |           scheme: 'MyScheme',
211 |           configuration: 'Debug',
212 |         },
213 |         mockExecutor,
214 |         mockFileSystemExecutor,
215 |       );
216 | 
217 |       expect(result.content).toBeDefined();
218 |       expect(Array.isArray(result.content)).toBe(true);
219 |       expect(result.isError).toBeUndefined();
220 |     });
221 | 
222 |     it('should use default configuration when not provided', async () => {
223 |       const mockExecutor = createMockExecutor({
224 |         success: true,
225 |         output: 'Test Suite All Tests passed',
226 |       });
227 | 
228 |       // Mock file system dependencies
229 |       const mockFileSystemExecutor = {
230 |         mkdtemp: async () => '/tmp/test-123',
231 |         rm: async () => {},
232 |         tmpdir: () => '/tmp',
233 |         stat: async () => ({ isDirectory: () => true }),
234 |       };
235 | 
236 |       const result = await testMacosLogic(
237 |         {
238 |           workspacePath: '/path/to/workspace.xcworkspace',
239 |           scheme: 'MyScheme',
240 |         },
241 |         mockExecutor,
242 |         mockFileSystemExecutor,
243 |       );
244 | 
245 |       expect(result.content).toBeDefined();
246 |       expect(Array.isArray(result.content)).toBe(true);
247 |       expect(result.isError).toBeUndefined();
248 |     });
249 | 
250 |     it('should handle optional parameters correctly', async () => {
251 |       const mockExecutor = createMockExecutor({
252 |         success: true,
253 |         output: 'Test Suite All Tests passed',
254 |       });
255 | 
256 |       // Mock file system dependencies
257 |       const mockFileSystemExecutor = {
258 |         mkdtemp: async () => '/tmp/test-123',
259 |         rm: async () => {},
260 |         tmpdir: () => '/tmp',
261 |         stat: async () => ({ isDirectory: () => true }),
262 |       };
263 | 
264 |       const result = await testMacosLogic(
265 |         {
266 |           workspacePath: '/path/to/workspace.xcworkspace',
267 |           scheme: 'MyScheme',
268 |           configuration: 'Release',
269 |           derivedDataPath: '/custom/derived',
270 |           extraArgs: ['--verbose'],
271 |           preferXcodebuild: true,
272 |         },
273 |         mockExecutor,
274 |         mockFileSystemExecutor,
275 |       );
276 | 
277 |       expect(result.content).toBeDefined();
278 |       expect(Array.isArray(result.content)).toBe(true);
279 |       expect(result.isError).toBeUndefined();
280 |     });
281 | 
282 |     it('should handle successful test execution with minimal parameters', async () => {
283 |       const mockExecutor = createMockExecutor({
284 |         success: true,
285 |         output: 'Test Suite All Tests passed',
286 |       });
287 | 
288 |       // Mock file system dependencies
289 |       const mockFileSystemExecutor = {
290 |         mkdtemp: async () => '/tmp/test-123',
291 |         rm: async () => {},
292 |         tmpdir: () => '/tmp',
293 |         stat: async () => ({ isDirectory: () => true }),
294 |       };
295 | 
296 |       const result = await testMacosLogic(
297 |         {
298 |           workspacePath: '/path/to/MyProject.xcworkspace',
299 |           scheme: 'MyApp',
300 |         },
301 |         mockExecutor,
302 |         mockFileSystemExecutor,
303 |       );
304 | 
305 |       expect(result.content).toBeDefined();
306 |       expect(Array.isArray(result.content)).toBe(true);
307 |       expect(result.isError).toBeUndefined();
308 |     });
309 | 
310 |     it('should return exact successful test response', async () => {
311 |       // Track command execution calls
312 |       const commandCalls: any[] = [];
313 | 
314 |       // Mock executor for successful test
315 |       const mockExecutor = async (
316 |         command: string[],
317 |         logPrefix?: string,
318 |         useShell?: boolean,
319 |         env?: Record<string, string>,
320 |       ) => {
321 |         commandCalls.push({ command, logPrefix, useShell, env });
322 | 
323 |         // Handle xcresulttool command
324 |         if (command.includes('xcresulttool')) {
325 |           return {
326 |             success: true,
327 |             output: JSON.stringify({
328 |               title: 'Test Results',
329 |               result: 'SUCCEEDED',
330 |               totalTestCount: 5,
331 |               passedTests: 5,
332 |               failedTests: 0,
333 |               skippedTests: 0,
334 |               expectedFailures: 0,
335 |             }),
336 |             error: undefined,
337 |           };
338 |         }
339 | 
340 |         return {
341 |           success: true,
342 |           output: 'Test Succeeded',
343 |           error: undefined,
344 |           process: { pid: 12345 },
345 |         };
346 |       };
347 | 
348 |       // Mock file system dependencies using approved utility
349 |       const mockFileSystemExecutor = {
350 |         mkdtemp: async () => '/tmp/xcodebuild-test-abc123',
351 |         rm: async () => {},
352 |         tmpdir: () => '/tmp',
353 |         stat: async () => ({ isDirectory: () => true }),
354 |       };
355 | 
356 |       const result = await testMacosLogic(
357 |         {
358 |           workspacePath: '/path/to/MyProject.xcworkspace',
359 |           scheme: 'MyScheme',
360 |         },
361 |         mockExecutor,
362 |         mockFileSystemExecutor,
363 |       );
364 | 
365 |       // Verify commands were called with correct parameters
366 |       expect(commandCalls).toHaveLength(2); // xcodebuild test + xcresulttool
367 |       expect(commandCalls[0].command).toEqual([
368 |         'xcodebuild',
369 |         '-workspace',
370 |         '/path/to/MyProject.xcworkspace',
371 |         '-scheme',
372 |         'MyScheme',
373 |         '-configuration',
374 |         'Debug',
375 |         '-skipMacroValidation',
376 |         '-destination',
377 |         'platform=macOS',
378 |         '-resultBundlePath',
379 |         '/tmp/xcodebuild-test-abc123/TestResults.xcresult',
380 |         'test',
381 |       ]);
382 |       expect(commandCalls[0].logPrefix).toBe('Test Run');
383 |       expect(commandCalls[0].useShell).toBe(true);
384 | 
385 |       // Verify xcresulttool was called
386 |       expect(commandCalls[1].command).toEqual([
387 |         'xcrun',
388 |         'xcresulttool',
389 |         'get',
390 |         'test-results',
391 |         'summary',
392 |         '--path',
393 |         '/tmp/xcodebuild-test-abc123/TestResults.xcresult',
394 |       ]);
395 |       expect(commandCalls[1].logPrefix).toBe('Parse xcresult bundle');
396 | 
397 |       expect(result.content).toEqual(
398 |         expect.arrayContaining([
399 |           expect.objectContaining({
400 |             type: 'text',
401 |             text: '✅ Test Run test succeeded for scheme MyScheme.',
402 |           }),
403 |         ]),
404 |       );
405 |     });
406 | 
407 |     it('should return exact test failure response', async () => {
408 |       // Track command execution calls
409 |       let callCount = 0;
410 |       const mockExecutor = async (
411 |         command: string[],
412 |         logPrefix?: string,
413 |         useShell?: boolean,
414 |         env?: Record<string, string>,
415 |       ) => {
416 |         callCount++;
417 | 
418 |         // First call is xcodebuild test - fails
419 |         if (callCount === 1) {
420 |           return {
421 |             success: false,
422 |             output: '',
423 |             error: 'error: Test failed',
424 |             process: { pid: 12345 },
425 |           };
426 |         }
427 | 
428 |         // Second call is xcresulttool
429 |         if (command.includes('xcresulttool')) {
430 |           return {
431 |             success: true,
432 |             output: JSON.stringify({
433 |               title: 'Test Results',
434 |               result: 'FAILED',
435 |               totalTestCount: 5,
436 |               passedTests: 3,
437 |               failedTests: 2,
438 |               skippedTests: 0,
439 |               expectedFailures: 0,
440 |             }),
441 |             error: undefined,
442 |           };
443 |         }
444 | 
445 |         return { success: true, output: '', error: undefined };
446 |       };
447 | 
448 |       // Mock file system dependencies
449 |       const mockFileSystemExecutor = {
450 |         mkdtemp: async () => '/tmp/xcodebuild-test-abc123',
451 |         rm: async () => {},
452 |         tmpdir: () => '/tmp',
453 |         stat: async () => ({ isDirectory: () => true }),
454 |       };
455 | 
456 |       const result = await testMacosLogic(
457 |         {
458 |           workspacePath: '/path/to/MyProject.xcworkspace',
459 |           scheme: 'MyScheme',
460 |         },
461 |         mockExecutor,
462 |         mockFileSystemExecutor,
463 |       );
464 | 
465 |       expect(result.content).toEqual(
466 |         expect.arrayContaining([
467 |           expect.objectContaining({
468 |             type: 'text',
469 |             text: '❌ Test Run test failed for scheme MyScheme.',
470 |           }),
471 |         ]),
472 |       );
473 |       expect(result.isError).toBe(true);
474 |     });
475 | 
476 |     it('should return exact successful test response with optional parameters', async () => {
477 |       // Track command execution calls
478 |       const commandCalls: any[] = [];
479 | 
480 |       // Mock executor for successful test with optional parameters
481 |       const mockExecutor = async (
482 |         command: string[],
483 |         logPrefix?: string,
484 |         useShell?: boolean,
485 |         env?: Record<string, string>,
486 |       ) => {
487 |         commandCalls.push({ command, logPrefix, useShell, env });
488 | 
489 |         // Handle xcresulttool command
490 |         if (command.includes('xcresulttool')) {
491 |           return {
492 |             success: true,
493 |             output: JSON.stringify({
494 |               title: 'Test Results',
495 |               result: 'SUCCEEDED',
496 |               totalTestCount: 5,
497 |               passedTests: 5,
498 |               failedTests: 0,
499 |               skippedTests: 0,
500 |               expectedFailures: 0,
501 |             }),
502 |             error: undefined,
503 |           };
504 |         }
505 | 
506 |         return {
507 |           success: true,
508 |           output: 'Test Succeeded',
509 |           error: undefined,
510 |           process: { pid: 12345 },
511 |         };
512 |       };
513 | 
514 |       // Mock file system dependencies
515 |       const mockFileSystemExecutor = {
516 |         mkdtemp: async () => '/tmp/xcodebuild-test-abc123',
517 |         rm: async () => {},
518 |         tmpdir: () => '/tmp',
519 |         stat: async () => ({ isDirectory: () => true }),
520 |       };
521 | 
522 |       const result = await testMacosLogic(
523 |         {
524 |           workspacePath: '/path/to/MyProject.xcworkspace',
525 |           scheme: 'MyScheme',
526 |           configuration: 'Release',
527 |           derivedDataPath: '/path/to/derived-data',
528 |           extraArgs: ['--verbose'],
529 |           preferXcodebuild: true,
530 |         },
531 |         mockExecutor,
532 |         mockFileSystemExecutor,
533 |       );
534 | 
535 |       expect(result.content).toEqual(
536 |         expect.arrayContaining([
537 |           expect.objectContaining({
538 |             type: 'text',
539 |             text: '✅ Test Run test succeeded for scheme MyScheme.',
540 |           }),
541 |         ]),
542 |       );
543 |     });
544 | 
545 |     it('should return exact exception handling response', async () => {
546 |       // Mock executor (won't be called due to mkdtemp failure)
547 |       const mockExecutor = createMockExecutor({
548 |         success: true,
549 |         output: 'Test Succeeded',
550 |       });
551 | 
552 |       // Mock file system dependencies - mkdtemp fails
553 |       const mockFileSystemExecutor = {
554 |         mkdtemp: async () => {
555 |           throw new Error('Network error');
556 |         },
557 |         rm: async () => {},
558 |         tmpdir: () => '/tmp',
559 |         stat: async () => ({ isDirectory: () => true }),
560 |       };
561 | 
562 |       const result = await testMacosLogic(
563 |         {
564 |           workspacePath: '/path/to/MyProject.xcworkspace',
565 |           scheme: 'MyScheme',
566 |         },
567 |         mockExecutor,
568 |         mockFileSystemExecutor,
569 |       );
570 | 
571 |       expect(result).toEqual({
572 |         content: [
573 |           {
574 |             type: 'text',
575 |             text: 'Error during test run: Network error',
576 |           },
577 |         ],
578 |         isError: true,
579 |       });
580 |     });
581 |   });
582 | });
583 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Tests for build_run_sim plugin (unified)
  3 |  * Following CLAUDE.md testing standards with dependency injection and literal validation
  4 |  */
  5 | 
  6 | import { describe, it, expect, beforeEach } from 'vitest';
  7 | import { z } from 'zod';
  8 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
  9 | import { sessionStore } from '../../../../utils/session-store.ts';
 10 | import buildRunSim, { build_run_simLogic } from '../build_run_sim.ts';
 11 | 
 12 | describe('build_run_sim tool', () => {
 13 |   beforeEach(() => {
 14 |     sessionStore.clear();
 15 |   });
 16 | 
 17 |   describe('Export Field Validation (Literal)', () => {
 18 |     it('should have correct name', () => {
 19 |       expect(buildRunSim.name).toBe('build_run_sim');
 20 |     });
 21 | 
 22 |     it('should have correct description', () => {
 23 |       expect(buildRunSim.description).toBe('Builds and runs an app on an iOS simulator.');
 24 |     });
 25 | 
 26 |     it('should have handler function', () => {
 27 |       expect(typeof buildRunSim.handler).toBe('function');
 28 |     });
 29 | 
 30 |     it('should expose only non-session fields in public schema', () => {
 31 |       const schema = z.object(buildRunSim.schema);
 32 | 
 33 |       expect(schema.safeParse({}).success).toBe(true);
 34 | 
 35 |       expect(
 36 |         schema.safeParse({
 37 |           derivedDataPath: '/path/to/derived',
 38 |           extraArgs: ['--verbose'],
 39 |           preferXcodebuild: false,
 40 |         }).success,
 41 |       ).toBe(true);
 42 | 
 43 |       expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false);
 44 |       expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false);
 45 |       expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false);
 46 | 
 47 |       const schemaKeys = Object.keys(buildRunSim.schema).sort();
 48 |       expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort());
 49 |       expect(schemaKeys).not.toContain('scheme');
 50 |       expect(schemaKeys).not.toContain('simulatorName');
 51 |       expect(schemaKeys).not.toContain('projectPath');
 52 |     });
 53 |   });
 54 | 
 55 |   describe('Handler Behavior (Complete Literal Returns)', () => {
 56 |     // Note: Parameter validation is now handled by createTypedTool wrapper with Zod schema
 57 |     // The logic function receives validated parameters, so these tests focus on business logic
 58 | 
 59 |     it('should handle simulator not found', async () => {
 60 |       let callCount = 0;
 61 |       const mockExecutor = async (command: string[]) => {
 62 |         callCount++;
 63 |         if (callCount === 1) {
 64 |           // First call: build succeeds
 65 |           return {
 66 |             success: true,
 67 |             output: 'BUILD SUCCEEDED',
 68 |             process: { pid: 12345 },
 69 |           };
 70 |         } else if (callCount === 2) {
 71 |           // Second call: showBuildSettings fails to get app path
 72 |           return {
 73 |             success: false,
 74 |             error: 'Could not get build settings',
 75 |             process: { pid: 12345 },
 76 |           };
 77 |         }
 78 |         return {
 79 |           success: false,
 80 |           error: 'Unexpected call',
 81 |           process: { pid: 12345 },
 82 |         };
 83 |       };
 84 | 
 85 |       const result = await build_run_simLogic(
 86 |         {
 87 |           workspacePath: '/path/to/workspace',
 88 |           scheme: 'MyScheme',
 89 |           simulatorName: 'iPhone 16',
 90 |         },
 91 |         mockExecutor,
 92 |       );
 93 | 
 94 |       expect(result).toEqual({
 95 |         content: [
 96 |           {
 97 |             type: 'text',
 98 |             text: 'Build succeeded, but failed to get app path: Could not get build settings',
 99 |           },
100 |         ],
101 |         isError: true,
102 |       });
103 |     });
104 | 
105 |     it('should handle build failure', async () => {
106 |       const mockExecutor = createMockExecutor({
107 |         success: false,
108 |         error: 'Build failed with error',
109 |       });
110 | 
111 |       const result = await build_run_simLogic(
112 |         {
113 |           workspacePath: '/path/to/workspace',
114 |           scheme: 'MyScheme',
115 |           simulatorName: 'iPhone 16',
116 |         },
117 |         mockExecutor,
118 |       );
119 | 
120 |       expect(result.isError).toBe(true);
121 |       expect(result.content).toBeDefined();
122 |       expect(Array.isArray(result.content)).toBe(true);
123 |     });
124 | 
125 |     it('should handle successful build and run', async () => {
126 |       // Create a mock executor that simulates full successful flow
127 |       let callCount = 0;
128 |       const mockExecutor = async (command: string[], logPrefix?: string) => {
129 |         callCount++;
130 | 
131 |         if (command.includes('xcodebuild') && command.includes('build')) {
132 |           // First call: build succeeds
133 |           return {
134 |             success: true,
135 |             output: 'BUILD SUCCEEDED',
136 |             process: { pid: 12345 },
137 |           };
138 |         } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) {
139 |           // Second call: build settings to get app path
140 |           return {
141 |             success: true,
142 |             output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n',
143 |             process: { pid: 12345 },
144 |           };
145 |         } else if (command.includes('simctl') && command.includes('list')) {
146 |           // Find simulator calls
147 |           return {
148 |             success: true,
149 |             output: JSON.stringify({
150 |               devices: {
151 |                 'iOS 16.0': [
152 |                   {
153 |                     udid: 'test-uuid-123',
154 |                     name: 'iPhone 16',
155 |                     state: 'Booted',
156 |                     isAvailable: true,
157 |                   },
158 |                 ],
159 |               },
160 |             }),
161 |             process: { pid: 12345 },
162 |           };
163 |         } else if (
164 |           command.includes('plutil') ||
165 |           command.includes('PlistBuddy') ||
166 |           command.includes('defaults')
167 |         ) {
168 |           // Bundle ID extraction
169 |           return {
170 |             success: true,
171 |             output: 'com.example.MyApp',
172 |             process: { pid: 12345 },
173 |           };
174 |         } else {
175 |           // All other commands (boot, open, install, launch) succeed
176 |           return {
177 |             success: true,
178 |             output: 'Success',
179 |             process: { pid: 12345 },
180 |           };
181 |         }
182 |       };
183 | 
184 |       const result = await build_run_simLogic(
185 |         {
186 |           workspacePath: '/path/to/workspace',
187 |           scheme: 'MyScheme',
188 |           simulatorName: 'iPhone 16',
189 |         },
190 |         mockExecutor,
191 |       );
192 | 
193 |       expect(result.content).toBeDefined();
194 |       expect(Array.isArray(result.content)).toBe(true);
195 |       expect(result.isError).toBe(false);
196 |     });
197 | 
198 |     it('should handle exception with Error object', async () => {
199 |       const mockExecutor = createMockExecutor({
200 |         success: false,
201 |         error: 'Command failed',
202 |       });
203 | 
204 |       const result = await build_run_simLogic(
205 |         {
206 |           workspacePath: '/path/to/workspace',
207 |           scheme: 'MyScheme',
208 |           simulatorName: 'iPhone 16',
209 |         },
210 |         mockExecutor,
211 |       );
212 | 
213 |       expect(result.isError).toBe(true);
214 |       expect(result.content).toBeDefined();
215 |       expect(Array.isArray(result.content)).toBe(true);
216 |     });
217 | 
218 |     it('should handle exception with string error', async () => {
219 |       const mockExecutor = createMockExecutor({
220 |         success: false,
221 |         error: 'String error',
222 |       });
223 | 
224 |       const result = await build_run_simLogic(
225 |         {
226 |           workspacePath: '/path/to/workspace',
227 |           scheme: 'MyScheme',
228 |           simulatorName: 'iPhone 16',
229 |         },
230 |         mockExecutor,
231 |       );
232 | 
233 |       expect(result.isError).toBe(true);
234 |       expect(result.content).toBeDefined();
235 |       expect(Array.isArray(result.content)).toBe(true);
236 |     });
237 |   });
238 | 
239 |   describe('Command Generation', () => {
240 |     it('should generate correct simctl list command with minimal parameters', async () => {
241 |       const callHistory: Array<{
242 |         command: string[];
243 |         logPrefix?: string;
244 |         useShell?: boolean;
245 |         env?: any;
246 |       }> = [];
247 | 
248 |       // Create tracking executor
249 |       const trackingExecutor = async (
250 |         command: string[],
251 |         logPrefix?: string,
252 |         useShell?: boolean,
253 |         env?: Record<string, string>,
254 |       ) => {
255 |         callHistory.push({ command, logPrefix, useShell, env });
256 |         return {
257 |           success: false,
258 |           output: '',
259 |           error: 'Test error to stop execution early',
260 |           process: { pid: 12345 },
261 |         };
262 |       };
263 | 
264 |       const result = await build_run_simLogic(
265 |         {
266 |           workspacePath: '/path/to/MyProject.xcworkspace',
267 |           scheme: 'MyScheme',
268 |           simulatorName: 'iPhone 16',
269 |         },
270 |         trackingExecutor,
271 |       );
272 | 
273 |       // Should generate the initial build command
274 |       expect(callHistory).toHaveLength(1);
275 |       expect(callHistory[0].command).toEqual([
276 |         'xcodebuild',
277 |         '-workspace',
278 |         '/path/to/MyProject.xcworkspace',
279 |         '-scheme',
280 |         'MyScheme',
281 |         '-configuration',
282 |         'Debug',
283 |         '-skipMacroValidation',
284 |         '-destination',
285 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
286 |         'build',
287 |       ]);
288 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
289 |     });
290 | 
291 |     it('should generate correct build command after finding simulator', async () => {
292 |       const callHistory: Array<{
293 |         command: string[];
294 |         logPrefix?: string;
295 |         useShell?: boolean;
296 |         env?: any;
297 |       }> = [];
298 | 
299 |       let callCount = 0;
300 |       // Create tracking executor that succeeds on first call (list) and fails on second
301 |       const trackingExecutor = async (
302 |         command: string[],
303 |         logPrefix?: string,
304 |         useShell?: boolean,
305 |         env?: Record<string, string>,
306 |       ) => {
307 |         callHistory.push({ command, logPrefix, useShell, env });
308 |         callCount++;
309 | 
310 |         if (callCount === 1) {
311 |           // First call: simulator list succeeds
312 |           return {
313 |             success: true,
314 |             output: JSON.stringify({
315 |               devices: {
316 |                 'iOS 16.0': [
317 |                   {
318 |                     udid: 'test-uuid-123',
319 |                     name: 'iPhone 16',
320 |                     state: 'Booted',
321 |                   },
322 |                 ],
323 |               },
324 |             }),
325 |             error: undefined,
326 |             process: { pid: 12345 },
327 |           };
328 |         } else {
329 |           // Second call: build command fails to stop execution
330 |           return {
331 |             success: false,
332 |             output: '',
333 |             error: 'Test error to stop execution',
334 |             process: { pid: 12345 },
335 |           };
336 |         }
337 |       };
338 | 
339 |       const result = await build_run_simLogic(
340 |         {
341 |           workspacePath: '/path/to/MyProject.xcworkspace',
342 |           scheme: 'MyScheme',
343 |           simulatorName: 'iPhone 16',
344 |         },
345 |         trackingExecutor,
346 |       );
347 | 
348 |       // Should generate build command and then build settings command
349 |       expect(callHistory).toHaveLength(2);
350 | 
351 |       // First call: build command
352 |       expect(callHistory[0].command).toEqual([
353 |         'xcodebuild',
354 |         '-workspace',
355 |         '/path/to/MyProject.xcworkspace',
356 |         '-scheme',
357 |         'MyScheme',
358 |         '-configuration',
359 |         'Debug',
360 |         '-skipMacroValidation',
361 |         '-destination',
362 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
363 |         'build',
364 |       ]);
365 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
366 | 
367 |       // Second call: build settings command to get app path
368 |       expect(callHistory[1].command).toEqual([
369 |         'xcodebuild',
370 |         '-showBuildSettings',
371 |         '-workspace',
372 |         '/path/to/MyProject.xcworkspace',
373 |         '-scheme',
374 |         'MyScheme',
375 |         '-configuration',
376 |         'Debug',
377 |         '-destination',
378 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
379 |       ]);
380 |       expect(callHistory[1].logPrefix).toBe('Get App Path');
381 |     });
382 | 
383 |     it('should generate correct build settings command after successful build', async () => {
384 |       const callHistory: Array<{
385 |         command: string[];
386 |         logPrefix?: string;
387 |         useShell?: boolean;
388 |         env?: any;
389 |       }> = [];
390 | 
391 |       let callCount = 0;
392 |       // Create tracking executor that succeeds on first two calls and fails on third
393 |       const trackingExecutor = async (
394 |         command: string[],
395 |         logPrefix?: string,
396 |         useShell?: boolean,
397 |         env?: Record<string, string>,
398 |       ) => {
399 |         callHistory.push({ command, logPrefix, useShell, env });
400 |         callCount++;
401 | 
402 |         if (callCount === 1) {
403 |           // First call: simulator list succeeds
404 |           return {
405 |             success: true,
406 |             output: JSON.stringify({
407 |               devices: {
408 |                 'iOS 16.0': [
409 |                   {
410 |                     udid: 'test-uuid-123',
411 |                     name: 'iPhone 16',
412 |                     state: 'Booted',
413 |                   },
414 |                 ],
415 |               },
416 |             }),
417 |             error: undefined,
418 |             process: { pid: 12345 },
419 |           };
420 |         } else if (callCount === 2) {
421 |           // Second call: build command succeeds
422 |           return {
423 |             success: true,
424 |             output: 'BUILD SUCCEEDED',
425 |             error: undefined,
426 |             process: { pid: 12345 },
427 |           };
428 |         } else {
429 |           // Third call: build settings command fails to stop execution
430 |           return {
431 |             success: false,
432 |             output: '',
433 |             error: 'Test error to stop execution',
434 |             process: { pid: 12345 },
435 |           };
436 |         }
437 |       };
438 | 
439 |       const result = await build_run_simLogic(
440 |         {
441 |           workspacePath: '/path/to/MyProject.xcworkspace',
442 |           scheme: 'MyScheme',
443 |           simulatorName: 'iPhone 16',
444 |           configuration: 'Release',
445 |           useLatestOS: false,
446 |         },
447 |         trackingExecutor,
448 |       );
449 | 
450 |       // Should generate build command and build settings command
451 |       expect(callHistory).toHaveLength(2);
452 | 
453 |       // First call: build command
454 |       expect(callHistory[0].command).toEqual([
455 |         'xcodebuild',
456 |         '-workspace',
457 |         '/path/to/MyProject.xcworkspace',
458 |         '-scheme',
459 |         'MyScheme',
460 |         '-configuration',
461 |         'Release',
462 |         '-skipMacroValidation',
463 |         '-destination',
464 |         'platform=iOS Simulator,name=iPhone 16',
465 |         'build',
466 |       ]);
467 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
468 | 
469 |       // Second call: build settings command
470 |       expect(callHistory[1].command).toEqual([
471 |         'xcodebuild',
472 |         '-showBuildSettings',
473 |         '-workspace',
474 |         '/path/to/MyProject.xcworkspace',
475 |         '-scheme',
476 |         'MyScheme',
477 |         '-configuration',
478 |         'Release',
479 |         '-destination',
480 |         'platform=iOS Simulator,name=iPhone 16',
481 |       ]);
482 |       expect(callHistory[1].logPrefix).toBe('Get App Path');
483 |     });
484 | 
485 |     it('should handle paths with spaces in command generation', async () => {
486 |       const callHistory: Array<{
487 |         command: string[];
488 |         logPrefix?: string;
489 |         useShell?: boolean;
490 |         env?: any;
491 |       }> = [];
492 | 
493 |       // Create tracking executor
494 |       const trackingExecutor = async (
495 |         command: string[],
496 |         logPrefix?: string,
497 |         useShell?: boolean,
498 |         env?: Record<string, string>,
499 |       ) => {
500 |         callHistory.push({ command, logPrefix, useShell, env });
501 |         return {
502 |           success: false,
503 |           output: '',
504 |           error: 'Test error to stop execution early',
505 |           process: { pid: 12345 },
506 |         };
507 |       };
508 | 
509 |       const result = await build_run_simLogic(
510 |         {
511 |           workspacePath: '/Users/dev/My Project/MyProject.xcworkspace',
512 |           scheme: 'My Scheme',
513 |           simulatorName: 'iPhone 16 Pro',
514 |         },
515 |         trackingExecutor,
516 |       );
517 | 
518 |       // Should generate build command first
519 |       expect(callHistory).toHaveLength(1);
520 |       expect(callHistory[0].command).toEqual([
521 |         'xcodebuild',
522 |         '-workspace',
523 |         '/Users/dev/My Project/MyProject.xcworkspace',
524 |         '-scheme',
525 |         'My Scheme',
526 |         '-configuration',
527 |         'Debug',
528 |         '-skipMacroValidation',
529 |         '-destination',
530 |         'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest',
531 |         'build',
532 |       ]);
533 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
534 |     });
535 |   });
536 | 
537 |   describe('XOR Validation', () => {
538 |     it('should error when neither projectPath nor workspacePath provided', async () => {
539 |       const result = await buildRunSim.handler({
540 |         scheme: 'MyScheme',
541 |         simulatorName: 'iPhone 16',
542 |       });
543 |       expect(result.isError).toBe(true);
544 |       expect(result.content[0].text).toContain('Missing required session defaults');
545 |       expect(result.content[0].text).toContain('Provide a project or workspace');
546 |     });
547 | 
548 |     it('should error when both projectPath and workspacePath provided', async () => {
549 |       const result = await buildRunSim.handler({
550 |         projectPath: '/path/project.xcodeproj',
551 |         workspacePath: '/path/workspace.xcworkspace',
552 |         scheme: 'MyScheme',
553 |         simulatorName: 'iPhone 16',
554 |       });
555 |       expect(result.isError).toBe(true);
556 |       expect(result.content[0].text).toContain('Parameter validation failed');
557 |       expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
558 |       expect(result.content[0].text).toContain('projectPath');
559 |       expect(result.content[0].text).toContain('workspacePath');
560 |     });
561 | 
562 |     it('should succeed with only projectPath', async () => {
563 |       // This test fails early due to build failure, which is expected behavior
564 |       const mockExecutor = createMockExecutor({
565 |         success: false,
566 |         error: 'Build failed',
567 |       });
568 | 
569 |       const result = await build_run_simLogic(
570 |         {
571 |           projectPath: '/path/project.xcodeproj',
572 |           scheme: 'MyScheme',
573 |           simulatorName: 'iPhone 16',
574 |         },
575 |         mockExecutor,
576 |       );
577 |       // The test succeeds if the logic function accepts the parameters and attempts to build
578 |       expect(result.isError).toBe(true);
579 |       expect(result.content[0].text).toContain('Build failed');
580 |     });
581 | 
582 |     it('should succeed with only workspacePath', async () => {
583 |       // This test fails early due to build failure, which is expected behavior
584 |       const mockExecutor = createMockExecutor({
585 |         success: false,
586 |         error: 'Build failed',
587 |       });
588 | 
589 |       const result = await build_run_simLogic(
590 |         {
591 |           workspacePath: '/path/workspace.xcworkspace',
592 |           scheme: 'MyScheme',
593 |           simulatorName: 'iPhone 16',
594 |         },
595 |         mockExecutor,
596 |       );
597 |       // The test succeeds if the logic function accepts the parameters and attempts to build
598 |       expect(result.isError).toBe(true);
599 |       expect(result.content[0].text).toContain('Build failed');
600 |     });
601 |   });
602 | });
603 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/simulator/build_run_sim.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Simulator Build & Run Plugin: Build Run Simulator (Unified)
  3 |  *
  4 |  * Builds and runs an app from a project or workspace on a specific simulator by UUID or name.
  5 |  * Accepts mutually exclusive `projectPath` or `workspacePath`.
  6 |  * Accepts mutually exclusive `simulatorId` or `simulatorName`.
  7 |  */
  8 | 
  9 | import { z } from 'zod';
 10 | import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts';
 11 | import { log } from '../../../utils/logging/index.ts';
 12 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
 13 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
 14 | import { createTextResponse } from '../../../utils/responses/index.ts';
 15 | import { executeXcodeBuildCommand } from '../../../utils/build/index.ts';
 16 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
 17 | import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts';
 18 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
 19 | 
 20 | // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName
 21 | const baseOptions = {
 22 |   scheme: z.string().describe('The scheme to use (Required)'),
 23 |   simulatorId: z
 24 |     .string()
 25 |     .optional()
 26 |     .describe(
 27 |       'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both',
 28 |     ),
 29 |   simulatorName: z
 30 |     .string()
 31 |     .optional()
 32 |     .describe(
 33 |       "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both",
 34 |     ),
 35 |   configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'),
 36 |   derivedDataPath: z
 37 |     .string()
 38 |     .optional()
 39 |     .describe('Path where build products and other derived data will go'),
 40 |   extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'),
 41 |   useLatestOS: z
 42 |     .boolean()
 43 |     .optional()
 44 |     .describe('Whether to use the latest OS version for the named simulator'),
 45 |   preferXcodebuild: z
 46 |     .boolean()
 47 |     .optional()
 48 |     .describe(
 49 |       'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.',
 50 |     ),
 51 | };
 52 | 
 53 | const baseSchemaObject = z.object({
 54 |   projectPath: z
 55 |     .string()
 56 |     .optional()
 57 |     .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'),
 58 |   workspacePath: z
 59 |     .string()
 60 |     .optional()
 61 |     .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'),
 62 |   ...baseOptions,
 63 | });
 64 | 
 65 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject);
 66 | 
 67 | const buildRunSimulatorSchema = baseSchema
 68 |   .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, {
 69 |     message: 'Either projectPath or workspacePath is required.',
 70 |   })
 71 |   .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), {
 72 |     message: 'projectPath and workspacePath are mutually exclusive. Provide only one.',
 73 |   })
 74 |   .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, {
 75 |     message: 'Either simulatorId or simulatorName is required.',
 76 |   })
 77 |   .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), {
 78 |     message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.',
 79 |   });
 80 | 
 81 | export type BuildRunSimulatorParams = z.infer<typeof buildRunSimulatorSchema>;
 82 | 
 83 | // Internal logic for building Simulator apps.
 84 | async function _handleSimulatorBuildLogic(
 85 |   params: BuildRunSimulatorParams,
 86 |   executor: CommandExecutor,
 87 |   executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand,
 88 | ): Promise<ToolResponse> {
 89 |   const projectType = params.projectPath ? 'project' : 'workspace';
 90 |   const filePath = params.projectPath ?? params.workspacePath;
 91 | 
 92 |   // Log warning if useLatestOS is provided with simulatorId
 93 |   if (params.simulatorId && params.useLatestOS !== undefined) {
 94 |     log(
 95 |       'warning',
 96 |       `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`,
 97 |     );
 98 |   }
 99 | 
100 |   log(
101 |     'info',
102 |     `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`,
103 |   );
104 | 
105 |   // Create SharedBuildParams object with required configuration property
106 |   const sharedBuildParams: SharedBuildParams = {
107 |     workspacePath: params.workspacePath,
108 |     projectPath: params.projectPath,
109 |     scheme: params.scheme,
110 |     configuration: params.configuration ?? 'Debug',
111 |     derivedDataPath: params.derivedDataPath,
112 |     extraArgs: params.extraArgs,
113 |   };
114 | 
115 |   return executeXcodeBuildCommandFn(
116 |     sharedBuildParams,
117 |     {
118 |       platform: XcodePlatform.iOSSimulator,
119 |       simulatorId: params.simulatorId,
120 |       simulatorName: params.simulatorName,
121 |       useLatestOS: params.simulatorId ? false : params.useLatestOS,
122 |       logPrefix: 'iOS Simulator Build',
123 |     },
124 |     params.preferXcodebuild as boolean,
125 |     'build',
126 |     executor,
127 |   );
128 | }
129 | 
130 | // Exported business logic function for building and running iOS Simulator apps.
131 | export async function build_run_simLogic(
132 |   params: BuildRunSimulatorParams,
133 |   executor: CommandExecutor,
134 |   executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand,
135 | ): Promise<ToolResponse> {
136 |   const projectType = params.projectPath ? 'project' : 'workspace';
137 |   const filePath = params.projectPath ?? params.workspacePath;
138 | 
139 |   log(
140 |     'info',
141 |     `Starting iOS Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`,
142 |   );
143 | 
144 |   try {
145 |     // --- Build Step ---
146 |     const buildResult = await _handleSimulatorBuildLogic(
147 |       params,
148 |       executor,
149 |       executeXcodeBuildCommandFn,
150 |     );
151 | 
152 |     if (buildResult.isError) {
153 |       return buildResult; // Return the build error
154 |     }
155 | 
156 |     // --- Get App Path Step ---
157 |     // Create the command array for xcodebuild with -showBuildSettings option
158 |     const command = ['xcodebuild', '-showBuildSettings'];
159 | 
160 |     // Add the workspace or project
161 |     if (params.workspacePath) {
162 |       command.push('-workspace', params.workspacePath);
163 |     } else if (params.projectPath) {
164 |       command.push('-project', params.projectPath);
165 |     }
166 | 
167 |     // Add the scheme and configuration
168 |     command.push('-scheme', params.scheme);
169 |     command.push('-configuration', params.configuration ?? 'Debug');
170 | 
171 |     // Handle destination for simulator
172 |     let destinationString: string;
173 |     if (params.simulatorId) {
174 |       destinationString = `platform=iOS Simulator,id=${params.simulatorId}`;
175 |     } else if (params.simulatorName) {
176 |       destinationString = `platform=iOS Simulator,name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`;
177 |     } else {
178 |       // This shouldn't happen due to validation, but handle it
179 |       destinationString = 'platform=iOS Simulator';
180 |     }
181 |     command.push('-destination', destinationString);
182 | 
183 |     // Add derived data path if provided
184 |     if (params.derivedDataPath) {
185 |       command.push('-derivedDataPath', params.derivedDataPath);
186 |     }
187 | 
188 |     // Add extra args if provided
189 |     if (params.extraArgs && params.extraArgs.length > 0) {
190 |       command.push(...params.extraArgs);
191 |     }
192 | 
193 |     // Execute the command directly
194 |     const result = await executor(command, 'Get App Path', true, undefined);
195 | 
196 |     // If there was an error with the command execution, return it
197 |     if (!result.success) {
198 |       return createTextResponse(
199 |         `Build succeeded, but failed to get app path: ${result.error ?? 'Unknown error'}`,
200 |         true,
201 |       );
202 |     }
203 | 
204 |     // Parse the output to extract the app path
205 |     const buildSettingsOutput = result.output;
206 | 
207 |     // Try both approaches to get app path - first the project approach (CODESIGNING_FOLDER_PATH)
208 |     let appBundlePath: string | null = null;
209 | 
210 |     // Project approach: Extract CODESIGNING_FOLDER_PATH from build settings to get app path
211 |     const appPathMatch = buildSettingsOutput.match(/CODESIGNING_FOLDER_PATH = (.+\.app)/);
212 |     if (appPathMatch?.[1]) {
213 |       appBundlePath = appPathMatch[1].trim();
214 |     } else {
215 |       // Workspace approach: Extract BUILT_PRODUCTS_DIR and FULL_PRODUCT_NAME
216 |       const builtProductsDirMatch = buildSettingsOutput.match(
217 |         /^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m,
218 |       );
219 |       const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m);
220 | 
221 |       if (builtProductsDirMatch && fullProductNameMatch) {
222 |         const builtProductsDir = builtProductsDirMatch[1].trim();
223 |         const fullProductName = fullProductNameMatch[1].trim();
224 |         appBundlePath = `${builtProductsDir}/${fullProductName}`;
225 |       }
226 |     }
227 | 
228 |     if (!appBundlePath) {
229 |       return createTextResponse(
230 |         `Build succeeded, but could not find app path in build settings.`,
231 |         true,
232 |       );
233 |     }
234 | 
235 |     log('info', `App bundle path for run: ${appBundlePath}`);
236 | 
237 |     // --- Find/Boot Simulator Step ---
238 |     // Use our helper to determine the simulator UUID
239 |     const uuidResult = await determineSimulatorUuid(
240 |       { simulatorUuid: params.simulatorId, simulatorName: params.simulatorName },
241 |       executor,
242 |     );
243 | 
244 |     if (uuidResult.error) {
245 |       return createTextResponse(`Build succeeded, but ${uuidResult.error.content[0].text}`, true);
246 |     }
247 | 
248 |     if (uuidResult.warning) {
249 |       log('warning', uuidResult.warning);
250 |     }
251 | 
252 |     const simulatorUuid = uuidResult.uuid;
253 | 
254 |     if (!simulatorUuid) {
255 |       return createTextResponse(
256 |         'Build succeeded, but no simulator specified and failed to find a suitable one.',
257 |         true,
258 |       );
259 |     }
260 | 
261 |     // Check simulator state and boot if needed
262 |     try {
263 |       log('info', `Checking simulator state for UUID: ${simulatorUuid}`);
264 |       const simulatorListResult = await executor(
265 |         ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'],
266 |         'List Simulators',
267 |       );
268 |       if (!simulatorListResult.success) {
269 |         throw new Error(simulatorListResult.error ?? 'Failed to list simulators');
270 |       }
271 | 
272 |       const simulatorsData = JSON.parse(simulatorListResult.output) as {
273 |         devices: Record<string, unknown[]>;
274 |       };
275 |       let targetSimulator: { udid: string; name: string; state: string } | null = null;
276 | 
277 |       // Find the target simulator
278 |       for (const runtime in simulatorsData.devices) {
279 |         const devices = simulatorsData.devices[runtime];
280 |         if (Array.isArray(devices)) {
281 |           for (const device of devices) {
282 |             if (
283 |               typeof device === 'object' &&
284 |               device !== null &&
285 |               'udid' in device &&
286 |               'name' in device &&
287 |               'state' in device &&
288 |               typeof device.udid === 'string' &&
289 |               typeof device.name === 'string' &&
290 |               typeof device.state === 'string' &&
291 |               device.udid === simulatorUuid
292 |             ) {
293 |               targetSimulator = {
294 |                 udid: device.udid,
295 |                 name: device.name,
296 |                 state: device.state,
297 |               };
298 |               break;
299 |             }
300 |           }
301 |           if (targetSimulator) break;
302 |         }
303 |       }
304 | 
305 |       if (!targetSimulator) {
306 |         return createTextResponse(
307 |           `Build succeeded, but could not find simulator with UUID: ${simulatorUuid}`,
308 |           true,
309 |         );
310 |       }
311 | 
312 |       // Boot if needed
313 |       if (targetSimulator.state !== 'Booted') {
314 |         log('info', `Booting simulator ${targetSimulator.name}...`);
315 |         const bootResult = await executor(
316 |           ['xcrun', 'simctl', 'boot', simulatorUuid],
317 |           'Boot Simulator',
318 |         );
319 |         if (!bootResult.success) {
320 |           throw new Error(bootResult.error ?? 'Failed to boot simulator');
321 |         }
322 |       } else {
323 |         log('info', `Simulator ${simulatorUuid} is already booted`);
324 |       }
325 |     } catch (error) {
326 |       const errorMessage = error instanceof Error ? error.message : String(error);
327 |       log('error', `Error checking/booting simulator: ${errorMessage}`);
328 |       return createTextResponse(
329 |         `Build succeeded, but error checking/booting simulator: ${errorMessage}`,
330 |         true,
331 |       );
332 |     }
333 | 
334 |     // --- Open Simulator UI Step ---
335 |     try {
336 |       log('info', 'Opening Simulator app');
337 |       const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App');
338 |       if (!openResult.success) {
339 |         throw new Error(openResult.error ?? 'Failed to open Simulator app');
340 |       }
341 |     } catch (error) {
342 |       const errorMessage = error instanceof Error ? error.message : String(error);
343 |       log('warning', `Warning: Could not open Simulator app: ${errorMessage}`);
344 |       // Don't fail the whole operation for this
345 |     }
346 | 
347 |     // --- Install App Step ---
348 |     try {
349 |       log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorUuid}`);
350 |       const installResult = await executor(
351 |         ['xcrun', 'simctl', 'install', simulatorUuid, appBundlePath],
352 |         'Install App',
353 |       );
354 |       if (!installResult.success) {
355 |         throw new Error(installResult.error ?? 'Failed to install app');
356 |       }
357 |     } catch (error) {
358 |       const errorMessage = error instanceof Error ? error.message : String(error);
359 |       log('error', `Error installing app: ${errorMessage}`);
360 |       return createTextResponse(
361 |         `Build succeeded, but error installing app on simulator: ${errorMessage}`,
362 |         true,
363 |       );
364 |     }
365 | 
366 |     // --- Get Bundle ID Step ---
367 |     let bundleId;
368 |     try {
369 |       log('info', `Extracting bundle ID from app: ${appBundlePath}`);
370 | 
371 |       // Try multiple methods to get bundle ID - first PlistBuddy, then plutil, then defaults
372 |       let bundleIdResult = null;
373 | 
374 |       // Method 1: PlistBuddy (most reliable)
375 |       try {
376 |         bundleIdResult = await executor(
377 |           [
378 |             '/usr/libexec/PlistBuddy',
379 |             '-c',
380 |             'Print :CFBundleIdentifier',
381 |             `${appBundlePath}/Info.plist`,
382 |           ],
383 |           'Get Bundle ID with PlistBuddy',
384 |         );
385 |         if (bundleIdResult.success) {
386 |           bundleId = bundleIdResult.output.trim();
387 |         }
388 |       } catch {
389 |         // Continue to next method
390 |       }
391 | 
392 |       // Method 2: plutil (workspace approach)
393 |       if (!bundleId) {
394 |         try {
395 |           bundleIdResult = await executor(
396 |             ['plutil', '-extract', 'CFBundleIdentifier', 'raw', `${appBundlePath}/Info.plist`],
397 |             'Get Bundle ID with plutil',
398 |           );
399 |           if (bundleIdResult?.success) {
400 |             bundleId = bundleIdResult.output?.trim();
401 |           }
402 |         } catch {
403 |           // Continue to next method
404 |         }
405 |       }
406 | 
407 |       // Method 3: defaults (fallback)
408 |       if (!bundleId) {
409 |         try {
410 |           bundleIdResult = await executor(
411 |             ['defaults', 'read', `${appBundlePath}/Info`, 'CFBundleIdentifier'],
412 |             'Get Bundle ID with defaults',
413 |           );
414 |           if (bundleIdResult?.success) {
415 |             bundleId = bundleIdResult.output?.trim();
416 |           }
417 |         } catch {
418 |           // All methods failed
419 |         }
420 |       }
421 | 
422 |       if (!bundleId) {
423 |         throw new Error('Could not extract bundle ID from Info.plist using any method');
424 |       }
425 | 
426 |       log('info', `Bundle ID for run: ${bundleId}`);
427 |     } catch (error) {
428 |       const errorMessage = error instanceof Error ? error.message : String(error);
429 |       log('error', `Error getting bundle ID: ${errorMessage}`);
430 |       return createTextResponse(
431 |         `Build and install succeeded, but error getting bundle ID: ${errorMessage}`,
432 |         true,
433 |       );
434 |     }
435 | 
436 |     // --- Launch App Step ---
437 |     try {
438 |       log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorUuid}`);
439 |       const launchResult = await executor(
440 |         ['xcrun', 'simctl', 'launch', simulatorUuid, bundleId],
441 |         'Launch App',
442 |       );
443 |       if (!launchResult.success) {
444 |         throw new Error(launchResult.error ?? 'Failed to launch app');
445 |       }
446 |     } catch (error) {
447 |       const errorMessage = error instanceof Error ? error.message : String(error);
448 |       log('error', `Error launching app: ${errorMessage}`);
449 |       return createTextResponse(
450 |         `Build and install succeeded, but error launching app on simulator: ${errorMessage}`,
451 |         true,
452 |       );
453 |     }
454 | 
455 |     // --- Success ---
456 |     log('info', '✅ iOS simulator build & run succeeded.');
457 | 
458 |     const target = params.simulatorId
459 |       ? `simulator UUID '${params.simulatorId}'`
460 |       : `simulator name '${params.simulatorName}'`;
461 |     const sourceType = params.projectPath ? 'project' : 'workspace';
462 |     const sourcePath = params.projectPath ?? params.workspacePath;
463 | 
464 |     return {
465 |       content: [
466 |         {
467 |           type: 'text',
468 |           text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.
469 |           
470 | The app (${bundleId}) is now running in the iOS Simulator. 
471 | If you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.
472 | 
473 | Next Steps:
474 | - Option 1: Capture structured logs only (app continues running):
475 |   start_simulator_log_capture({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}' })
476 | - Option 2: Capture both console and structured logs (app will restart):
477 |   start_simulator_log_capture({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}', captureConsole: true })
478 | - Option 3: Launch app with logs in one step (for a fresh start):
479 |   launch_app_with_logs_in_simulator({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}' })
480 | 
481 | When done with any option, use: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
482 |         },
483 |       ],
484 |       isError: false,
485 |     };
486 |   } catch (error) {
487 |     const errorMessage = error instanceof Error ? error.message : String(error);
488 |     log('error', `Error in iOS Simulator build and run: ${errorMessage}`);
489 |     return createTextResponse(`Error in iOS Simulator build and run: ${errorMessage}`, true);
490 |   }
491 | }
492 | 
493 | const publicSchemaObject = baseSchemaObject.omit({
494 |   projectPath: true,
495 |   workspacePath: true,
496 |   scheme: true,
497 |   configuration: true,
498 |   simulatorId: true,
499 |   simulatorName: true,
500 |   useLatestOS: true,
501 | } as const);
502 | 
503 | export default {
504 |   name: 'build_run_sim',
505 |   description: 'Builds and runs an app on an iOS simulator.',
506 |   schema: publicSchemaObject.shape,
507 |   handler: createSessionAwareTool<BuildRunSimulatorParams>({
508 |     internalSchema: buildRunSimulatorSchema as unknown as z.ZodType<BuildRunSimulatorParams>,
509 |     logicFunction: build_run_simLogic,
510 |     getExecutor: getDefaultCommandExecutor,
511 |     requirements: [
512 |       { allOf: ['scheme'], message: 'scheme is required' },
513 |       { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
514 |       { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
515 |     ],
516 |     exclusivePairs: [
517 |       ['projectPath', 'workspacePath'],
518 |       ['simulatorId', 'simulatorName'],
519 |     ],
520 |   }),
521 | };
522 | 
```

--------------------------------------------------------------------------------
/docs/session_management_plan.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Stateful Session Defaults for MCP Tools — Design, Middleware, and Plan
  2 | 
  3 | Below is a concise architecture and implementation plan to introduce a session-aware defaults layer that removes repeated tool parameters from public schemas, while keeping all tool logic and tests unchanged.
  4 | 
  5 | ## Architecture Overview
  6 | 
  7 | - **Core idea**: keep logic functions and tests untouched; move argument consolidation into a session-aware interop layer and expose minimal public schemas.
  8 | - **Data flow**:
  9 |   - Client calls a tool with zero or few args → session middleware merges session defaults → validates with the internal schema → calls the existing logic function.
 10 | - **Components**:
 11 |   - `SessionStore` (singleton, in-memory): set/get/clear/show defaults.
 12 |   - Session-aware tool factory: merges defaults, performs preflight requirement checks (allOf/oneOf), then validates with the tool's internal zod schema.
 13 |   - Public vs internal schema: plugins register a minimal "public" input schema; handlers validate with the unchanged "internal" schema.
 14 | 
 15 | ## Core Types
 16 | 
 17 | ```typescript
 18 | // src/utils/session-store.ts
 19 | export type SessionDefaults = {
 20 |   projectPath?: string;
 21 |   workspacePath?: string;
 22 |   scheme?: string;
 23 |   configuration?: string;
 24 |   simulatorName?: string;
 25 |   simulatorId?: string;
 26 |   deviceId?: string;
 27 |   useLatestOS?: boolean;
 28 |   arch?: 'arm64' | 'x86_64';
 29 | };
 30 | ```
 31 | 
 32 | ## Session Store (singleton)
 33 | 
 34 | ```typescript
 35 | // src/utils/session-store.ts
 36 | import { log } from './logger.ts';
 37 | 
 38 | class SessionStore {
 39 |   private defaults: SessionDefaults = {};
 40 | 
 41 |   setDefaults(partial: Partial<SessionDefaults>): void {
 42 |     this.defaults = { ...this.defaults, ...partial };
 43 |     log('info', '[Session] Defaults set', { keys: Object.keys(partial) });
 44 |   }
 45 | 
 46 |   clear(keys?: (keyof SessionDefaults)[]): void {
 47 |     if (!keys || keys.length === 0) {
 48 |       this.defaults = {};
 49 |       log('info', '[Session] All defaults cleared');
 50 |       return;
 51 |     }
 52 |     for (const k of keys) delete this.defaults[k];
 53 |     log('info', '[Session] Defaults cleared', { keys });
 54 |   }
 55 | 
 56 |   get<K extends keyof SessionDefaults>(key: K): SessionDefaults[K] {
 57 |     return this.defaults[key];
 58 |   }
 59 | 
 60 |   getAll(): SessionDefaults {
 61 |     return { ...this.defaults };
 62 |   }
 63 | }
 64 | 
 65 | export const sessionStore = new SessionStore();
 66 | ```
 67 | 
 68 | ## Session-Aware Tool Factory
 69 | 
 70 | ```typescript
 71 | // src/utils/typed-tool-factory.ts (add new helper, keep createTypedTool as-is)
 72 | import { z } from 'zod';
 73 | import { sessionStore, type SessionDefaults } from './session-store.ts';
 74 | import type { CommandExecutor } from './execution/index.ts';
 75 | import { createErrorResponse } from './responses/index.ts';
 76 | import type { ToolResponse } from '../types/common.ts';
 77 | 
 78 | export type SessionRequirement =
 79 |   | { allOf: (keyof SessionDefaults)[]; message?: string }
 80 |   | { oneOf: (keyof SessionDefaults)[]; message?: string };
 81 | 
 82 | function missingFromArgsAndSession(
 83 |   keys: (keyof SessionDefaults)[],
 84 |   args: Record<string, unknown>,
 85 | ): string[] {
 86 |   return keys.filter((k) => args[k] == null && sessionStore.get(k) == null);
 87 | }
 88 | 
 89 | export function createSessionAwareTool<TParams>(opts: {
 90 |   internalSchema: z.ZodType<TParams>;
 91 |   logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>;
 92 |   getExecutor: () => CommandExecutor;
 93 |   // Optional extras to improve UX and ergonomics
 94 |   sessionKeys?: (keyof SessionDefaults)[];
 95 |   requirements?: SessionRequirement[]; // preflight, friendlier than raw zod errors
 96 | }) {
 97 |   const { internalSchema, logicFunction, getExecutor, sessionKeys = [], requirements = [] } = opts;
 98 | 
 99 |   return async (rawArgs: Record<string, unknown>): Promise<ToolResponse> => {
100 |     try {
101 |       // Merge: explicit args take precedence over session defaults
102 |       const merged: Record<string, unknown> = { ...sessionStore.getAll(), ...rawArgs };
103 | 
104 |       // Preflight requirement checks (clear message how to fix)
105 |       for (const req of requirements) {
106 |         if ('allOf' in req) {
107 |           const missing = missingFromArgsAndSession(req.allOf, rawArgs);
108 |           if (missing.length > 0) {
109 |             return createErrorResponse(
110 |               'Missing required session defaults',
111 |               `${req.message ?? `Required: ${req.allOf.join(', ')}`}\n` +
112 |                 `Set with: session-set-defaults { ${missing.map((k) => `"${k}": "..."`).join(', ')} }`,
113 |             );
114 |           }
115 |         } else if ('oneOf' in req) {
116 |           const missing = missingFromArgsAndSession(req.oneOf, rawArgs);
117 |           // oneOf satisfied if at least one is present in merged
118 |           const satisfied = req.oneOf.some((k) => merged[k] != null);
119 |           if (!satisfied) {
120 |             return createErrorResponse(
121 |               'Missing required session defaults',
122 |               `${req.message ?? `Provide one of: ${req.oneOf.join(', ')}`}\n` +
123 |                 `Set with: session-set-defaults { "${req.oneOf[0]}": "..." }`,
124 |             );
125 |           }
126 |         }
127 |       }
128 | 
129 |       // Validate against unchanged internal schema (logic/api untouched)
130 |       const validated = internalSchema.parse(merged);
131 |       return await logicFunction(validated, getExecutor());
132 |     } catch (error) {
133 |       if (error instanceof z.ZodError) {
134 |         const msgs = error.errors.map((e) => `${e.path.join('.') || 'root'}: ${e.message}`);
135 |         return createErrorResponse(
136 |           'Parameter validation failed',
137 |           `Invalid parameters:\n${msgs.join('\n')}\n` +
138 |             `Tip: set session defaults via session-set-defaults`,
139 |         );
140 |       }
141 |       throw error;
142 |     }
143 |   };
144 | }
145 | ```
146 | 
147 | ## Plugin Migration Pattern (Example: build_sim)
148 | 
149 | Public schema hides session fields; handler uses session-aware factory with internal schema and requirements; logic function unchanged.
150 | 
151 | ```typescript
152 | // src/mcp/tools/simulator/build_sim.ts (key parts only)
153 | import { z } from 'zod';
154 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
155 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
156 | 
157 | // Existing internal schema (unchanged)…
158 | const baseOptions = { /* as-is (scheme, simulatorId, simulatorName, configuration, …) */ };
159 | const baseSchemaObject = z.object({
160 |   projectPath: z.string().optional(),
161 |   workspacePath: z.string().optional(),
162 |   ...baseOptions,
163 | });
164 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject);
165 | const buildSimulatorSchema = baseSchema
166 |   .refine(/* as-is: projectPath XOR workspacePath */)
167 |   .refine(/* as-is: simulatorId XOR simulatorName */);
168 | 
169 | export type BuildSimulatorParams = z.infer<typeof buildSimulatorSchema>;
170 | 
171 | // Public schema = internal minus session-managed fields
172 | const sessionManaged = [
173 |   'projectPath',
174 |   'workspacePath',
175 |   'scheme',
176 |   'configuration',
177 |   'simulatorId',
178 |   'simulatorName',
179 |   'useLatestOS',
180 | ] as const;
181 | 
182 | const publicSchemaObject = baseSchemaObject.omit(
183 |   Object.fromEntries(sessionManaged.map((k) => [k, true])) as Record<string, true>,
184 | );
185 | 
186 | export default {
187 |   name: 'build_sim',
188 |   description: 'Builds an app for an iOS simulator.',
189 |   schema: publicSchemaObject.shape, // what the MCP client sees
190 |   handler: createSessionAwareTool<BuildSimulatorParams>({
191 |     internalSchema: buildSimulatorSchema,
192 |     logicFunction: build_simLogic,
193 |     getExecutor: getDefaultCommandExecutor,
194 |     sessionKeys: sessionManaged,
195 |     requirements: [
196 |       { allOf: ['scheme'], message: 'scheme is required' },
197 |       { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
198 |       { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
199 |     ],
200 |   }),
201 | };
202 | ```
203 | 
204 | This same pattern applies to `build_run_sim`, `test_sim`, device/macos tools, etc. Public schemas become minimal, while internal schemas and logic remain unchanged.
205 | 
206 | ## New Tool Group: session-management
207 | 
208 | ### session_set_defaults.ts
209 | 
210 | ```typescript
211 | // src/mcp/tools/session-management/session_set_defaults.ts
212 | import { z } from 'zod';
213 | import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts';
214 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
215 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
216 | 
217 | const schemaObj = z.object({
218 |   projectPath: z.string().optional(),
219 |   workspacePath: z.string().optional(),
220 |   scheme: z.string().optional(),
221 |   configuration: z.string().optional(),
222 |   simulatorName: z.string().optional(),
223 |   simulatorId: z.string().optional(),
224 |   deviceId: z.string().optional(),
225 |   useLatestOS: z.boolean().optional(),
226 |   arch: z.enum(['arm64', 'x86_64']).optional(),
227 | });
228 | type Params = z.infer<typeof schemaObj>;
229 | 
230 | async function logic(params: Params): Promise<import('../../../types/common.ts').ToolResponse> {
231 |   sessionStore.setDefaults(params as Partial<SessionDefaults>);
232 |   const current = sessionStore.getAll();
233 |   return { content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }] };
234 | }
235 | 
236 | export default {
237 |   name: 'session-set-defaults',
238 |   description: 'Set session defaults used by other tools.',
239 |   schema: schemaObj.shape,
240 |   handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor),
241 | };
242 | ```
243 | 
244 | ### session_clear_defaults.ts
245 | 
246 | ```typescript
247 | // src/mcp/tools/session-management/session_clear_defaults.ts
248 | import { z } from 'zod';
249 | import { sessionStore } from '../../../utils/session-store.ts';
250 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
251 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
252 | 
253 | const keys = [
254 |   'projectPath','workspacePath','scheme','configuration',
255 |   'simulatorName','simulatorId','deviceId','useLatestOS','arch',
256 | ] as const;
257 | const schemaObj = z.object({
258 |   keys: z.array(z.enum(keys)).optional(),
259 |   all: z.boolean().optional(),
260 | });
261 | 
262 | async function logic(params: z.infer<typeof schemaObj>) {
263 |   if (params.all || !params.keys) sessionStore.clear();
264 |   else sessionStore.clear(params.keys);
265 |   return { content: [{ type: 'text', text: 'Session defaults cleared' }] };
266 | }
267 | 
268 | export default {
269 |   name: 'session-clear-defaults',
270 |   description: 'Clear selected or all session defaults.',
271 |   schema: schemaObj.shape,
272 |   handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor),
273 | };
274 | ```
275 | 
276 | ### session_show_defaults.ts
277 | 
278 | ```typescript
279 | // src/mcp/tools/session-management/session_show_defaults.ts
280 | import { sessionStore } from '../../../utils/session-store.ts';
281 | 
282 | export default {
283 |   name: 'session-show-defaults',
284 |   description: 'Show current session defaults.',
285 |   schema: {}, // no args
286 |   handler: async () => {
287 |     const current = sessionStore.getAll();
288 |     return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }] };
289 |   },
290 | };
291 | ```
292 | 
293 | ## Step-by-Step Implementation Plan (Incremental, buildable at each step)
294 | 
295 | 1. **Add SessionStore** ✅ **DONE**
296 |    - New file: `src/utils/session-store.ts`.
297 |    - No existing code changes; run: `npm run build`, `lint`, `test`.
298 |    - Commit checkpoint (after review): see Commit & Review Protocol below.
299 | 
300 | 2. **Add session-management tools** ✅ **DONE**
301 |    - New folder: `src/mcp/tools/session-management` with the three tools above.
302 |    - Register via existing plugin discovery (same pattern as others).
303 |    - Build and test.
304 |    - Commit checkpoint (after review).
305 | 
306 | 3. **Add session-aware tool factory** ✅ **DONE**
307 |    - Add `createSessionAwareTool` to `src/utils/typed-tool-factory.ts` (keep `createTypedTool` intact).
308 |    - Unit tests for requirement preflight and merge precedence.
309 |    - Commit checkpoint (after review).
310 | 
311 | 4. **Migrate 2-3 representative tools**
312 |    - Example: `simulator/build_sim`, `macos/build_macos`, `device/build_device`.
313 |    - Create `publicSchemaObject` (omit session fields), switch handler to `createSessionAwareTool` with requirements.
314 |    - Keep internal schema and logic unchanged. Build and test.
315 |    - Commit checkpoint (after review).
316 | 
317 | 5. **Migrate remaining tools in small batches**
318 |    - Apply the same pattern across simulator/device/macos/test utilities.
319 |    - After each batch: `npm run typecheck`, `lint`, `test`.
320 |    - Commit checkpoint (after review).
321 | 
322 | 6. **Final polish**
323 |    - Add tests for session tools and session-aware preflight error messages.
324 |    - Ensure public schemas no longer expose session parameters globally.
325 |    - Commit checkpoint (after review).
326 | 
327 | ## Standard Testing & DI Checklist (Mandatory)
328 | 
329 | - Handlers must use dependency injection; tests must never call real executors.
330 | - For validation-only tests, calling the handler is acceptable because Zod validation occurs before executor acquisition.
331 | - For logic tests that would otherwise trigger `getDefaultCommandExecutor`, export the logic function and test it directly (no executor needed if logic doesn’t use one):
332 | 
333 | ```ts
334 | // Example: src/mcp/tools/session-management/session_clear_defaults.ts
335 | export async function sessionClearDefaultsLogic(params: Params): Promise<ToolResponse> { /* ... */ }
336 | export default {
337 |   name: 'session-clear-defaults',
338 |   handler: createTypedTool(schemaObj, sessionClearDefaultsLogic, getDefaultCommandExecutor),
339 | };
340 | 
341 | // Test: import logic and call directly to avoid real executor
342 | import plugin, { sessionClearDefaultsLogic } from '../session_clear_defaults.ts';
343 | ```
344 | 
345 | - Add tests for the new group and tools:
346 |   - Group metadata test: `src/mcp/tools/session-management/__tests__/index.test.ts`
347 |   - Tool tests: `session_set_defaults.test.ts`, `session_clear_defaults.test.ts`, `session_show_defaults.test.ts`
348 |   - Utils tests: `src/utils/__tests__/session-store.test.ts`
349 |   - Factory tests: `src/utils/__tests__/session-aware-tool-factory.test.ts` covering:
350 |     - Preflight requirements (allOf/oneOf)
351 |     - Merge precedence (explicit args override session defaults)
352 |     - Zod error reporting with helpful tips
353 | 
354 | - Always run locally before requesting review:
355 |   - `npm run typecheck`
356 |   - `npm run lint`
357 |   - `npm run format:check`
358 |   - `npm run build`
359 |   - `npm run test`
360 |   - Perform a quick manual CLI check (mcpli or reloaderoo) per the Manual Testing section
361 | 
362 | ### Minimal Changes Policy for Tests (Enforced)
363 | 
364 | - Only make material, essential edits to tests required by the code change (e.g., new preflight error messages or added/removed fields).
365 | - Do not change sample input values or defaults in tests (e.g., flipping a boolean like `preferXcodebuild`) unless strictly necessary to validate behavior.
366 | - Preserve the original intent and coverage of logic-function tests; keep handler vs logic boundaries intact.
367 | - When session-awareness is added, prefer setting/clearing session defaults around tests rather than altering existing assertions or sample inputs.
368 | 
369 | ### Tool Description Policy (Enforced)
370 | 
371 | - Keep tool descriptions concise (maximum one short sentence).
372 | - Do not mention session defaults, setup steps, examples, or parameter relationships in descriptions.
373 | - Use clear, imperative phrasing (e.g., "Builds an app for an iOS simulator.").
374 | - Apply consistently across all migrated tools; update any tests that assert `description` to match the concise string only.
375 | 
376 | ## Commit & Review Protocol (Enforced)
377 | 
378 | At the end of each numbered step above:
379 | 
380 | 1. Ensure all checks pass: `typecheck`, `lint`, `format:check`, `build`, `test`; then perform a quick manual CLI test (mcpli or reloaderoo) per the Manual Testing section.
381 |    - Verify tool descriptions comply with the Tool Description Policy (concise, no session-defaults mention).
382 | 2. Stage only the files for that step.
383 | 3. Prepare a concise commit message focused on the “why”.
384 | 4. Request manual review and approval before committing. Do not push.
385 | 
386 | Example messages per step:
387 | 
388 | - Step 1 (SessionStore)
389 |   - `chore(utils): add in-memory SessionStore for session defaults`
390 |   - Body: “Introduces singleton SessionStore with set/get/clear/show for session defaults; no behavior changes.”
391 | 
392 | - Step 2 (session-management tools)
393 |   - `feat(session-management): add set/clear/show session defaults tools and workflow metadata`
394 |   - Body: “Adds tools to manage session defaults and exposes workflow metadata; minimal schemas via typed factory.”
395 | 
396 | - Step 3 (middleware)
397 |   - `feat(utils): add createSessionAwareTool with preflight requirements and args>session merge`
398 |   - Body: “Session-aware interop layer performing requirements checks and Zod validation against internal schema.”
399 | 
400 | - Step 6 (tests/final polish)
401 |   - `test(session-management): add tool, store, and middleware tests; export logic for DI`
402 |   - Body: “Covers group metadata, tools, SessionStore, and factory (requirements/merge/errors). No production behavior changes.”
403 | 
404 | Approval flow:
405 | - After preparing messages and confirming checks, request maintainer approval.
406 | - On approval: commit locally (no push).
407 | - On rejection: revise and re-run checks.
408 | 
409 | Note on commit hooks and selective commits:
410 | - The pre-commit hook runs format/lint/build and can auto-add or modify files, causing additional files to be included in the commit. If you must commit a minimal subset, skip hooks with: `git commit --no-verify` (use sparingly and run `npm run typecheck && npm run lint && npm run test` manually first).
411 | 
412 | ## Safety, Buildability, Testability
413 | 
414 | - Logic functions and their types remain unchanged; existing unit tests that import logic directly continue to pass.
415 | - Public schemas shrink; MCP clients see smaller input schemas without session fields.
416 | - Handlers validate with internal schemas after session-defaults merge, preserving runtime guarantees.
417 | - Preflight requirement checks return clear guidance, e.g., "Provide one of: projectPath or workspacePath" + "Set with: session-set-defaults { "projectPath": "..." }".
418 | 
419 | ## Developer Usage
420 | 
421 | - **Set defaults once**:
422 |   - `session-set-defaults { "workspacePath": "...", "scheme": "App", "simulatorName": "iPhone 16" }`
423 | - **Run tools without args**:
424 |   - `build_sim {}`
425 | - **Inspect/reset**:
426 |   - `session-show-defaults {}`
427 |   - `session-clear-defaults { "all": true }`
428 | 
429 | ## Manual Testing with mcpli (CLI)
430 | 
431 | The following commands exercise the session workflow end‑to‑end using the built server.
432 | 
433 | 1) Build the server (required after code changes):
434 | 
435 | ```bash
436 | npm run build
437 | ```
438 | 
439 | 2) Discover a scheme (optional helper):
440 | 
441 | ```bash
442 | mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/index.js
443 | ```
444 | 
445 | 3) Set the session defaults (project/workspace, scheme, and simulator):
446 | 
447 | ```bash
448 | mcpli --raw session-set-defaults \
449 |   --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" \
450 |   --scheme MCPTest \
451 |   --simulatorName "iPhone 16" \
452 |   -- node build/index.js
453 | ```
454 | 
455 | 4) Verify defaults are stored:
456 | 
457 | ```bash
458 | mcpli --raw session-show-defaults -- node build/index.js
459 | ```
460 | 
461 | 5) Run a session‑aware tool with zero or minimal args (defaults are merged automatically):
462 | 
463 | ```bash
464 | # Optionally provide a scratch derived data path and a short timeout
465 | mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/index.js
466 | ```
467 | 
468 | Troubleshooting:
469 | 
470 | - If you see validation errors like “Missing required session defaults …”, (re)run step 3 with the missing keys.
471 | - If you see connect ECONNREFUSED or the daemon appears flaky:
472 |   - Check logs: `mcpli daemon log --since=10m -- node build/index.js`
473 |   - Restart daemon: `mcpli daemon restart -- node build/index.js`
474 |   - Clean daemon state: `mcpli daemon clean -- node build/index.js` then `mcpli daemon start -- node build/index.js`
475 |   - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/index.js`
476 | 
477 | Notes:
478 | 
479 | - Public schemas for session‑aware tools intentionally omit session fields (e.g., `scheme`, `projectPath`, `simulatorName`). Provide them once via `session-set-defaults` and then call the tool with zero/minimal flags.
480 | - Use `--tool-timeout=<seconds>` to cap long‑running builds during manual testing.
481 | - mcpli CLI normalizes tool names: tools exported with underscores (e.g., `build_sim`) can be invoked with hyphens (e.g., `build-sim`). Copy/paste samples using hyphens are valid because mcpli converts underscores to dashes.
482 | 
483 | ## Next Steps
484 | 
485 | Would you like me to proceed with Phase 1–3 implementation (store + session tools + middleware), then migrate a first tool (build_sim) and run the test suite?
```

--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/build_sim.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach } from 'vitest';
  2 | import { z } from 'zod';
  3 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
  4 | import { sessionStore } from '../../../../utils/session-store.ts';
  5 | 
  6 | // Import the plugin and logic function
  7 | import buildSim, { build_simLogic } from '../build_sim.ts';
  8 | 
  9 | describe('build_sim tool', () => {
 10 |   beforeEach(() => {
 11 |     sessionStore.clear();
 12 |   });
 13 | 
 14 |   describe('Export Field Validation (Literal)', () => {
 15 |     it('should have correct name', () => {
 16 |       expect(buildSim.name).toBe('build_sim');
 17 |     });
 18 | 
 19 |     it('should have correct description', () => {
 20 |       expect(buildSim.description).toBe('Builds an app for an iOS simulator.');
 21 |     });
 22 | 
 23 |     it('should have handler function', () => {
 24 |       expect(typeof buildSim.handler).toBe('function');
 25 |     });
 26 | 
 27 |     it('should have correct public schema (only non-session fields)', () => {
 28 |       const schema = z.object(buildSim.schema);
 29 | 
 30 |       // Public schema should allow empty input
 31 |       expect(schema.safeParse({}).success).toBe(true);
 32 | 
 33 |       // Valid public inputs
 34 |       expect(
 35 |         schema.safeParse({
 36 |           derivedDataPath: '/path/to/derived',
 37 |           extraArgs: ['--verbose'],
 38 |           preferXcodebuild: false,
 39 |         }).success,
 40 |       ).toBe(true);
 41 | 
 42 |       // Invalid types on public inputs
 43 |       expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false);
 44 |       expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false);
 45 |       expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false);
 46 |     });
 47 |   });
 48 | 
 49 |   describe('Parameter Validation', () => {
 50 |     it('should handle missing both projectPath and workspacePath', async () => {
 51 |       const result = await buildSim.handler({
 52 |         scheme: 'MyScheme',
 53 |         simulatorName: 'iPhone 16',
 54 |       });
 55 | 
 56 |       expect(result.isError).toBe(true);
 57 |       expect(result.content[0].text).toContain('Missing required session defaults');
 58 |       expect(result.content[0].text).toContain('Provide a project or workspace');
 59 |     });
 60 | 
 61 |     it('should handle both projectPath and workspacePath provided', async () => {
 62 |       const result = await buildSim.handler({
 63 |         projectPath: '/path/to/project.xcodeproj',
 64 |         workspacePath: '/path/to/workspace',
 65 |         scheme: 'MyScheme',
 66 |         simulatorName: 'iPhone 16',
 67 |       });
 68 | 
 69 |       expect(result.isError).toBe(true);
 70 |       expect(result.content[0].text).toContain('Parameter validation failed');
 71 |       expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
 72 |       expect(result.content[0].text).toContain('projectPath');
 73 |       expect(result.content[0].text).toContain('workspacePath');
 74 |     });
 75 | 
 76 |     it('should handle empty workspacePath parameter', async () => {
 77 |       const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' });
 78 | 
 79 |       const result = await build_simLogic(
 80 |         {
 81 |           workspacePath: '',
 82 |           scheme: 'MyScheme',
 83 |           simulatorName: 'iPhone 16',
 84 |         },
 85 |         mockExecutor,
 86 |       );
 87 | 
 88 |       // Empty string passes validation but may cause build issues
 89 |       expect(result.content).toEqual([
 90 |         {
 91 |           type: 'text',
 92 |           text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.',
 93 |         },
 94 |         {
 95 |           type: 'text',
 96 |           text: expect.stringContaining('Next Steps:'),
 97 |         },
 98 |       ]);
 99 |     });
100 | 
101 |     it('should handle missing scheme parameter', async () => {
102 |       const result = await buildSim.handler({
103 |         workspacePath: '/path/to/workspace',
104 |         simulatorName: 'iPhone 16',
105 |       });
106 | 
107 |       expect(result.isError).toBe(true);
108 |       expect(result.content[0].text).toContain('Missing required session defaults');
109 |       expect(result.content[0].text).toContain('scheme is required');
110 |     });
111 | 
112 |     it('should handle empty scheme parameter', async () => {
113 |       const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' });
114 | 
115 |       const result = await build_simLogic(
116 |         {
117 |           workspacePath: '/path/to/workspace',
118 |           scheme: '',
119 |           simulatorName: 'iPhone 16',
120 |         },
121 |         mockExecutor,
122 |       );
123 | 
124 |       // Empty string passes validation but may cause build issues
125 |       expect(result.content).toEqual([
126 |         {
127 |           type: 'text',
128 |           text: '✅ iOS Simulator Build build succeeded for scheme .',
129 |         },
130 |         {
131 |           type: 'text',
132 |           text: expect.stringContaining('Next Steps:'),
133 |         },
134 |       ]);
135 |     });
136 | 
137 |     it('should handle missing both simulatorId and simulatorName', async () => {
138 |       const result = await buildSim.handler({
139 |         workspacePath: '/path/to/workspace',
140 |         scheme: 'MyScheme',
141 |       });
142 | 
143 |       expect(result.isError).toBe(true);
144 |       expect(result.content[0].text).toContain('Missing required session defaults');
145 |       expect(result.content[0].text).toContain('Provide simulatorId or simulatorName');
146 |     });
147 | 
148 |     it('should handle both simulatorId and simulatorName provided', async () => {
149 |       const mockExecutor = createMockExecutor({ success: true, output: 'Build succeeded' });
150 | 
151 |       // Should fail with XOR validation
152 |       const result = await buildSim.handler({
153 |         workspacePath: '/path/to/workspace',
154 |         scheme: 'MyScheme',
155 |         simulatorId: 'ABC-123',
156 |         simulatorName: 'iPhone 16',
157 |       });
158 | 
159 |       expect(result.isError).toBe(true);
160 |       expect(result.content[0].text).toContain('Parameter validation failed');
161 |       expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
162 |       expect(result.content[0].text).toContain('simulatorId');
163 |       expect(result.content[0].text).toContain('simulatorName');
164 |     });
165 | 
166 |     it('should handle empty simulatorName parameter', async () => {
167 |       const mockExecutor = createMockExecutor({
168 |         success: false,
169 |         output: '',
170 |         error: 'For iOS Simulator platform, either simulatorId or simulatorName must be provided',
171 |       });
172 | 
173 |       const result = await build_simLogic(
174 |         {
175 |           workspacePath: '/path/to/workspace',
176 |           scheme: 'MyScheme',
177 |           simulatorName: '',
178 |         },
179 |         mockExecutor,
180 |       );
181 | 
182 |       // Empty simulatorName passes validation but causes early failure in destination construction
183 |       expect(result.isError).toBe(true);
184 |       expect(result.content[0].text).toBe(
185 |         'For iOS Simulator platform, either simulatorId or simulatorName must be provided',
186 |       );
187 |     });
188 |   });
189 | 
190 |   describe('Command Generation', () => {
191 |     it('should generate correct build command with minimal parameters (workspace)', async () => {
192 |       const callHistory: Array<{
193 |         command: string[];
194 |         logPrefix?: string;
195 |         useShell?: boolean;
196 |         env?: any;
197 |       }> = [];
198 | 
199 |       // Create tracking executor
200 |       const trackingExecutor = async (
201 |         command: string[],
202 |         logPrefix?: string,
203 |         useShell?: boolean,
204 |         env?: Record<string, string>,
205 |       ) => {
206 |         callHistory.push({ command, logPrefix, useShell, env });
207 |         return {
208 |           success: false,
209 |           output: '',
210 |           error: 'Test error to stop execution early',
211 |           process: { pid: 12345 },
212 |         };
213 |       };
214 | 
215 |       const result = await build_simLogic(
216 |         {
217 |           workspacePath: '/path/to/MyProject.xcworkspace',
218 |           scheme: 'MyScheme',
219 |           simulatorName: 'iPhone 16',
220 |         },
221 |         trackingExecutor,
222 |       );
223 | 
224 |       // Should generate one build command
225 |       expect(callHistory).toHaveLength(1);
226 |       expect(callHistory[0].command).toEqual([
227 |         'xcodebuild',
228 |         '-workspace',
229 |         '/path/to/MyProject.xcworkspace',
230 |         '-scheme',
231 |         'MyScheme',
232 |         '-configuration',
233 |         'Debug',
234 |         '-skipMacroValidation',
235 |         '-destination',
236 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
237 |         'build',
238 |       ]);
239 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
240 |     });
241 | 
242 |     it('should generate correct build command with minimal parameters (project)', async () => {
243 |       const callHistory: Array<{
244 |         command: string[];
245 |         logPrefix?: string;
246 |         useShell?: boolean;
247 |         env?: any;
248 |       }> = [];
249 | 
250 |       // Create tracking executor
251 |       const trackingExecutor = async (
252 |         command: string[],
253 |         logPrefix?: string,
254 |         useShell?: boolean,
255 |         env?: Record<string, string>,
256 |       ) => {
257 |         callHistory.push({ command, logPrefix, useShell, env });
258 |         return {
259 |           success: false,
260 |           output: '',
261 |           error: 'Test error to stop execution early',
262 |           process: { pid: 12345 },
263 |         };
264 |       };
265 | 
266 |       const result = await build_simLogic(
267 |         {
268 |           projectPath: '/path/to/MyProject.xcodeproj',
269 |           scheme: 'MyScheme',
270 |           simulatorName: 'iPhone 16',
271 |         },
272 |         trackingExecutor,
273 |       );
274 | 
275 |       // Should generate one build command
276 |       expect(callHistory).toHaveLength(1);
277 |       expect(callHistory[0].command).toEqual([
278 |         'xcodebuild',
279 |         '-project',
280 |         '/path/to/MyProject.xcodeproj',
281 |         '-scheme',
282 |         'MyScheme',
283 |         '-configuration',
284 |         'Debug',
285 |         '-skipMacroValidation',
286 |         '-destination',
287 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
288 |         'build',
289 |       ]);
290 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
291 |     });
292 | 
293 |     it('should generate correct build command with all optional parameters', async () => {
294 |       const callHistory: Array<{
295 |         command: string[];
296 |         logPrefix?: string;
297 |         useShell?: boolean;
298 |         env?: any;
299 |       }> = [];
300 | 
301 |       // Create tracking executor
302 |       const trackingExecutor = async (
303 |         command: string[],
304 |         logPrefix?: string,
305 |         useShell?: boolean,
306 |         env?: Record<string, string>,
307 |       ) => {
308 |         callHistory.push({ command, logPrefix, useShell, env });
309 |         return {
310 |           success: false,
311 |           output: '',
312 |           error: 'Test error to stop execution early',
313 |           process: { pid: 12345 },
314 |         };
315 |       };
316 | 
317 |       const result = await build_simLogic(
318 |         {
319 |           workspacePath: '/path/to/MyProject.xcworkspace',
320 |           scheme: 'MyScheme',
321 |           simulatorName: 'iPhone 16',
322 |           configuration: 'Release',
323 |           derivedDataPath: '/custom/derived/path',
324 |           extraArgs: ['--verbose'],
325 |           useLatestOS: false,
326 |         },
327 |         trackingExecutor,
328 |       );
329 | 
330 |       // Should generate one build command with all parameters
331 |       expect(callHistory).toHaveLength(1);
332 |       expect(callHistory[0].command).toEqual([
333 |         'xcodebuild',
334 |         '-workspace',
335 |         '/path/to/MyProject.xcworkspace',
336 |         '-scheme',
337 |         'MyScheme',
338 |         '-configuration',
339 |         'Release',
340 |         '-skipMacroValidation',
341 |         '-destination',
342 |         'platform=iOS Simulator,name=iPhone 16',
343 |         '-derivedDataPath',
344 |         '/custom/derived/path',
345 |         '--verbose',
346 |         'build',
347 |       ]);
348 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
349 |     });
350 | 
351 |     it('should handle paths with spaces in command generation', async () => {
352 |       const callHistory: Array<{
353 |         command: string[];
354 |         logPrefix?: string;
355 |         useShell?: boolean;
356 |         env?: any;
357 |       }> = [];
358 | 
359 |       // Create tracking executor
360 |       const trackingExecutor = async (
361 |         command: string[],
362 |         logPrefix?: string,
363 |         useShell?: boolean,
364 |         env?: Record<string, string>,
365 |       ) => {
366 |         callHistory.push({ command, logPrefix, useShell, env });
367 |         return {
368 |           success: false,
369 |           output: '',
370 |           error: 'Test error to stop execution early',
371 |           process: { pid: 12345 },
372 |         };
373 |       };
374 | 
375 |       const result = await build_simLogic(
376 |         {
377 |           workspacePath: '/Users/dev/My Project/MyProject.xcworkspace',
378 |           scheme: 'My Scheme',
379 |           simulatorName: 'iPhone 16 Pro',
380 |         },
381 |         trackingExecutor,
382 |       );
383 | 
384 |       // Should generate one build command with paths containing spaces
385 |       expect(callHistory).toHaveLength(1);
386 |       expect(callHistory[0].command).toEqual([
387 |         'xcodebuild',
388 |         '-workspace',
389 |         '/Users/dev/My Project/MyProject.xcworkspace',
390 |         '-scheme',
391 |         'My Scheme',
392 |         '-configuration',
393 |         'Debug',
394 |         '-skipMacroValidation',
395 |         '-destination',
396 |         'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest',
397 |         'build',
398 |       ]);
399 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
400 |     });
401 | 
402 |     it('should generate correct build command with useLatestOS set to true', async () => {
403 |       const callHistory: Array<{
404 |         command: string[];
405 |         logPrefix?: string;
406 |         useShell?: boolean;
407 |         env?: any;
408 |       }> = [];
409 | 
410 |       // Create tracking executor
411 |       const trackingExecutor = async (
412 |         command: string[],
413 |         logPrefix?: string,
414 |         useShell?: boolean,
415 |         env?: Record<string, string>,
416 |       ) => {
417 |         callHistory.push({ command, logPrefix, useShell, env });
418 |         return {
419 |           success: false,
420 |           output: '',
421 |           error: 'Test error to stop execution early',
422 |           process: { pid: 12345 },
423 |         };
424 |       };
425 | 
426 |       const result = await build_simLogic(
427 |         {
428 |           workspacePath: '/path/to/MyProject.xcworkspace',
429 |           scheme: 'MyScheme',
430 |           simulatorName: 'iPhone 16',
431 |           useLatestOS: true,
432 |         },
433 |         trackingExecutor,
434 |       );
435 | 
436 |       // Should generate one build command with OS=latest
437 |       expect(callHistory).toHaveLength(1);
438 |       expect(callHistory[0].command).toEqual([
439 |         'xcodebuild',
440 |         '-workspace',
441 |         '/path/to/MyProject.xcworkspace',
442 |         '-scheme',
443 |         'MyScheme',
444 |         '-configuration',
445 |         'Debug',
446 |         '-skipMacroValidation',
447 |         '-destination',
448 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
449 |         'build',
450 |       ]);
451 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
452 |     });
453 |   });
454 | 
455 |   describe('Response Processing', () => {
456 |     it('should handle successful build', async () => {
457 |       const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' });
458 | 
459 |       const result = await build_simLogic(
460 |         {
461 |           workspacePath: '/path/to/workspace',
462 |           scheme: 'MyScheme',
463 |           simulatorName: 'iPhone 16',
464 |         },
465 |         mockExecutor,
466 |       );
467 | 
468 |       expect(result.content).toEqual([
469 |         {
470 |           type: 'text',
471 |           text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.',
472 |         },
473 |         {
474 |           type: 'text',
475 |           text: expect.stringContaining('Next Steps:'),
476 |         },
477 |       ]);
478 |     });
479 | 
480 |     it('should handle successful build with all optional parameters', async () => {
481 |       const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' });
482 | 
483 |       const result = await build_simLogic(
484 |         {
485 |           workspacePath: '/path/to/workspace',
486 |           scheme: 'MyScheme',
487 |           simulatorName: 'iPhone 16',
488 |           configuration: 'Release',
489 |           derivedDataPath: '/path/to/derived',
490 |           extraArgs: ['--verbose'],
491 |           useLatestOS: false,
492 |           preferXcodebuild: true,
493 |         },
494 |         mockExecutor,
495 |       );
496 | 
497 |       expect(result.content).toEqual([
498 |         {
499 |           type: 'text',
500 |           text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.',
501 |         },
502 |         {
503 |           type: 'text',
504 |           text: expect.stringContaining('Next Steps:'),
505 |         },
506 |       ]);
507 |     });
508 | 
509 |     it('should handle build failure', async () => {
510 |       const mockExecutor = createMockExecutor({
511 |         success: false,
512 |         output: '',
513 |         error: 'Build failed: Compilation error',
514 |       });
515 | 
516 |       const result = await build_simLogic(
517 |         {
518 |           workspacePath: '/path/to/workspace',
519 |           scheme: 'MyScheme',
520 |           simulatorName: 'iPhone 16',
521 |         },
522 |         mockExecutor,
523 |       );
524 | 
525 |       expect(result).toEqual({
526 |         content: [
527 |           {
528 |             type: 'text',
529 |             text: '❌ [stderr] Build failed: Compilation error',
530 |           },
531 |           {
532 |             type: 'text',
533 |             text: '❌ iOS Simulator Build build failed for scheme MyScheme.',
534 |           },
535 |         ],
536 |         isError: true,
537 |       });
538 |     });
539 | 
540 |     it('should handle build warnings', async () => {
541 |       const mockExecutor = createMockExecutor({
542 |         success: true,
543 |         output: 'warning: deprecated method used\nBUILD SUCCEEDED',
544 |       });
545 | 
546 |       const result = await build_simLogic(
547 |         {
548 |           workspacePath: '/path/to/workspace',
549 |           scheme: 'MyScheme',
550 |           simulatorName: 'iPhone 16',
551 |         },
552 |         mockExecutor,
553 |       );
554 | 
555 |       expect(result.content).toEqual(
556 |         expect.arrayContaining([
557 |           {
558 |             type: 'text',
559 |             text: expect.stringContaining('⚠️'),
560 |           },
561 |           {
562 |             type: 'text',
563 |             text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.',
564 |           },
565 |           {
566 |             type: 'text',
567 |             text: expect.stringContaining('Next Steps:'),
568 |           },
569 |         ]),
570 |       );
571 |     });
572 | 
573 |     it('should handle command executor errors', async () => {
574 |       const mockExecutor = createMockExecutor({
575 |         success: false,
576 |         error: 'spawn xcodebuild ENOENT',
577 |       });
578 | 
579 |       const result = await build_simLogic(
580 |         {
581 |           workspacePath: '/path/to/workspace',
582 |           scheme: 'MyScheme',
583 |           simulatorName: 'iPhone 16',
584 |         },
585 |         mockExecutor,
586 |       );
587 | 
588 |       expect(result.isError).toBe(true);
589 |       expect(result.content[0].text).toBe('❌ [stderr] spawn xcodebuild ENOENT');
590 |     });
591 | 
592 |     it('should handle mixed warning and error output', async () => {
593 |       const mockExecutor = createMockExecutor({
594 |         success: false,
595 |         output: 'warning: deprecated method\nerror: undefined symbol',
596 |         error: 'Build failed',
597 |       });
598 | 
599 |       const result = await build_simLogic(
600 |         {
601 |           workspacePath: '/path/to/workspace',
602 |           scheme: 'MyScheme',
603 |           simulatorName: 'iPhone 16',
604 |         },
605 |         mockExecutor,
606 |       );
607 | 
608 |       expect(result.isError).toBe(true);
609 |       expect(result.content).toEqual([
610 |         {
611 |           type: 'text',
612 |           text: '⚠️ Warning: warning: deprecated method',
613 |         },
614 |         {
615 |           type: 'text',
616 |           text: '❌ Error: error: undefined symbol',
617 |         },
618 |         {
619 |           type: 'text',
620 |           text: '❌ [stderr] Build failed',
621 |         },
622 |         {
623 |           type: 'text',
624 |           text: '❌ iOS Simulator Build build failed for scheme MyScheme.',
625 |         },
626 |       ]);
627 |     });
628 | 
629 |     it('should use default configuration when not provided', async () => {
630 |       const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' });
631 | 
632 |       const result = await build_simLogic(
633 |         {
634 |           workspacePath: '/path/to/workspace',
635 |           scheme: 'MyScheme',
636 |           simulatorName: 'iPhone 16',
637 |           // configuration intentionally omitted - should default to Debug
638 |         },
639 |         mockExecutor,
640 |       );
641 | 
642 |       expect(result.content).toEqual([
643 |         {
644 |           type: 'text',
645 |           text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.',
646 |         },
647 |         {
648 |           type: 'text',
649 |           text: expect.stringContaining('Next Steps:'),
650 |         },
651 |       ]);
652 |     });
653 |   });
654 | 
655 |   describe('Error Handling', () => {
656 |     it('should handle catch block exceptions', async () => {
657 |       // Create a mock that throws an error when called
658 |       const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' });
659 | 
660 |       // Mock the handler to throw an error by passing invalid parameters to internal functions
661 |       const result = await build_simLogic(
662 |         {
663 |           workspacePath: '/path/to/workspace',
664 |           scheme: 'MyScheme',
665 |           simulatorName: 'iPhone 16',
666 |         },
667 |         mockExecutor,
668 |       );
669 | 
670 |       // Should handle the build successfully
671 |       expect(result.content).toEqual([
672 |         {
673 |           type: 'text',
674 |           text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.',
675 |         },
676 |         {
677 |           type: 'text',
678 |           text: expect.stringContaining('Next Steps:'),
679 |         },
680 |       ]);
681 |     });
682 |   });
683 | });
684 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/logging/start_device_log_cap.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Logging Plugin: Start Device Log Capture
  3 |  *
  4 |  * Starts capturing logs from a specified Apple device by launching the app with console output.
  5 |  */
  6 | 
  7 | import * as fs from 'fs';
  8 | import * as path from 'path';
  9 | import * as os from 'os';
 10 | import type { ChildProcess } from 'child_process';
 11 | import { v4 as uuidv4 } from 'uuid';
 12 | import { z } from 'zod';
 13 | import { log } from '../../../utils/logging/index.ts';
 14 | import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts';
 15 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
 16 | import { ToolResponse } from '../../../types/common.ts';
 17 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
 18 | 
 19 | /**
 20 |  * Log file retention policy for device logs:
 21 |  * - Old log files (older than LOG_RETENTION_DAYS) are automatically deleted from the temp directory
 22 |  * - Cleanup runs on every new log capture start
 23 |  */
 24 | const LOG_RETENTION_DAYS = 3;
 25 | const DEVICE_LOG_FILE_PREFIX = 'xcodemcp_device_log_';
 26 | 
 27 | // Note: Device and simulator logging use different approaches due to platform constraints:
 28 | // - Simulators use 'xcrun simctl' with console-pty and OSLog stream capabilities
 29 | // - Devices use 'xcrun devicectl' with console output only (no OSLog streaming)
 30 | // The different command structures and output formats make sharing infrastructure complex.
 31 | // However, both follow similar patterns for session management and log retention.
 32 | export interface DeviceLogSession {
 33 |   process: ChildProcess;
 34 |   logFilePath: string;
 35 |   deviceUuid: string;
 36 |   bundleId: string;
 37 |   logStream?: fs.WriteStream;
 38 |   hasEnded: boolean;
 39 | }
 40 | 
 41 | export const activeDeviceLogSessions = new Map<string, DeviceLogSession>();
 42 | 
 43 | const EARLY_FAILURE_WINDOW_MS = 5000;
 44 | const INITIAL_OUTPUT_LIMIT = 8_192;
 45 | const DEFAULT_JSON_RESULT_WAIT_MS = 8000;
 46 | 
 47 | const FAILURE_PATTERNS = [
 48 |   /The application failed to launch/i,
 49 |   /Provide a valid bundle identifier/i,
 50 |   /The requested application .* is not installed/i,
 51 |   /NSOSStatusErrorDomain/i,
 52 |   /NSLocalizedFailureReason/i,
 53 |   /ERROR:/i,
 54 | ];
 55 | 
 56 | type JsonOutcome = {
 57 |   errorMessage?: string;
 58 |   pid?: number;
 59 | };
 60 | 
 61 | type DevicectlLaunchJson = {
 62 |   result?: {
 63 |     process?: {
 64 |       processIdentifier?: unknown;
 65 |     };
 66 |   };
 67 |   error?: {
 68 |     code?: unknown;
 69 |     domain?: unknown;
 70 |     localizedDescription?: unknown;
 71 |     userInfo?: Record<string, unknown> | undefined;
 72 |   };
 73 | };
 74 | 
 75 | function getJsonResultWaitMs(): number {
 76 |   const raw = process.env.XBMCP_LAUNCH_JSON_WAIT_MS;
 77 |   if (raw === undefined) {
 78 |     return DEFAULT_JSON_RESULT_WAIT_MS;
 79 |   }
 80 | 
 81 |   const parsed = Number(raw);
 82 |   if (!Number.isFinite(parsed) || parsed < 0) {
 83 |     return DEFAULT_JSON_RESULT_WAIT_MS;
 84 |   }
 85 | 
 86 |   return parsed;
 87 | }
 88 | 
 89 | function safeParseJson(text: string): DevicectlLaunchJson | null {
 90 |   try {
 91 |     const parsed = JSON.parse(text) as unknown;
 92 |     if (!parsed || typeof parsed !== 'object') {
 93 |       return null;
 94 |     }
 95 |     return parsed as DevicectlLaunchJson;
 96 |   } catch {
 97 |     return null;
 98 |   }
 99 | }
100 | 
101 | function extractJsonOutcome(json: DevicectlLaunchJson | null): JsonOutcome | null {
102 |   if (!json) {
103 |     return null;
104 |   }
105 | 
106 |   const resultProcess = json.result?.process;
107 |   const pidValue = resultProcess?.processIdentifier;
108 |   if (typeof pidValue === 'number' && Number.isFinite(pidValue)) {
109 |     return { pid: pidValue };
110 |   }
111 | 
112 |   const error = json.error;
113 |   if (!error) {
114 |     return null;
115 |   }
116 | 
117 |   const parts: string[] = [];
118 | 
119 |   if (typeof error.localizedDescription === 'string' && error.localizedDescription.length > 0) {
120 |     parts.push(error.localizedDescription);
121 |   }
122 | 
123 |   const userInfo = error.userInfo ?? {};
124 |   const recovery = userInfo?.NSLocalizedRecoverySuggestion;
125 |   const failureReason = userInfo?.NSLocalizedFailureReason;
126 |   const bundleIdentifier = userInfo?.BundleIdentifier;
127 | 
128 |   if (typeof failureReason === 'string' && failureReason.length > 0) {
129 |     parts.push(failureReason);
130 |   }
131 | 
132 |   if (typeof recovery === 'string' && recovery.length > 0) {
133 |     parts.push(recovery);
134 |   }
135 | 
136 |   if (typeof bundleIdentifier === 'string' && bundleIdentifier.length > 0) {
137 |     parts.push(`BundleIdentifier = ${bundleIdentifier}`);
138 |   }
139 | 
140 |   const domain = error.domain;
141 |   const code = error.code;
142 |   const domainPart = typeof domain === 'string' && domain.length > 0 ? domain : undefined;
143 |   const codePart = typeof code === 'number' && Number.isFinite(code) ? code : undefined;
144 | 
145 |   if (domainPart || codePart !== undefined) {
146 |     parts.push(`(${domainPart ?? 'UnknownDomain'} code ${codePart ?? 'unknown'})`);
147 |   }
148 | 
149 |   if (parts.length === 0) {
150 |     return { errorMessage: 'Launch failed' };
151 |   }
152 | 
153 |   return { errorMessage: parts.join('\n') };
154 | }
155 | 
156 | async function removeFileIfExists(
157 |   targetPath: string,
158 |   fileExecutor?: FileSystemExecutor,
159 | ): Promise<void> {
160 |   try {
161 |     if (fileExecutor) {
162 |       if (fileExecutor.existsSync(targetPath)) {
163 |         await fileExecutor.rm(targetPath, { force: true });
164 |       }
165 |       return;
166 |     }
167 | 
168 |     if (fs.existsSync(targetPath)) {
169 |       await fs.promises.rm(targetPath, { force: true });
170 |     }
171 |   } catch {
172 |     // Best-effort cleanup only
173 |   }
174 | }
175 | 
176 | async function pollJsonOutcome(
177 |   jsonPath: string,
178 |   fileExecutor: FileSystemExecutor | undefined,
179 |   timeoutMs: number,
180 | ): Promise<JsonOutcome | null> {
181 |   const start = Date.now();
182 | 
183 |   const readOnce = async (): Promise<JsonOutcome | null> => {
184 |     try {
185 |       const exists = fileExecutor?.existsSync(jsonPath) ?? fs.existsSync(jsonPath);
186 | 
187 |       if (!exists) {
188 |         return null;
189 |       }
190 | 
191 |       const content = fileExecutor
192 |         ? await fileExecutor.readFile(jsonPath, 'utf8')
193 |         : await fs.promises.readFile(jsonPath, 'utf8');
194 | 
195 |       const outcome = extractJsonOutcome(safeParseJson(content));
196 |       if (outcome) {
197 |         await removeFileIfExists(jsonPath, fileExecutor);
198 |         return outcome;
199 |       }
200 |     } catch {
201 |       // File may still be written; try again later
202 |     }
203 | 
204 |     return null;
205 |   };
206 | 
207 |   const immediate = await readOnce();
208 |   if (immediate) {
209 |     return immediate;
210 |   }
211 | 
212 |   if (timeoutMs <= 0) {
213 |     return null;
214 |   }
215 | 
216 |   let delay = Math.min(100, Math.max(10, Math.floor(timeoutMs / 4) || 10));
217 | 
218 |   while (Date.now() - start < timeoutMs) {
219 |     await new Promise((resolve) => setTimeout(resolve, delay));
220 |     const result = await readOnce();
221 |     if (result) {
222 |       return result;
223 |     }
224 |     delay = Math.min(400, delay + 50);
225 |   }
226 | 
227 |   return null;
228 | }
229 | 
230 | type WriteStreamWithClosed = fs.WriteStream & { closed?: boolean };
231 | 
232 | /**
233 |  * Start a log capture session for an iOS device by launching the app with console output.
234 |  * Uses the devicectl command to launch the app and capture console logs.
235 |  * Returns { sessionId, error? }
236 |  */
237 | export async function startDeviceLogCapture(
238 |   params: {
239 |     deviceUuid: string;
240 |     bundleId: string;
241 |   },
242 |   executor: CommandExecutor = getDefaultCommandExecutor(),
243 |   fileSystemExecutor?: FileSystemExecutor,
244 | ): Promise<{ sessionId: string; error?: string }> {
245 |   // Clean up old logs before starting a new session
246 |   await cleanOldDeviceLogs();
247 | 
248 |   const { deviceUuid, bundleId } = params;
249 |   const logSessionId = uuidv4();
250 |   const logFileName = `${DEVICE_LOG_FILE_PREFIX}${logSessionId}.log`;
251 |   const tempDir = fileSystemExecutor ? fileSystemExecutor.tmpdir() : os.tmpdir();
252 |   const logFilePath = path.join(tempDir, logFileName);
253 |   const launchJsonPath = path.join(tempDir, `devicectl-launch-${logSessionId}.json`);
254 | 
255 |   let logStream: fs.WriteStream | undefined;
256 | 
257 |   try {
258 |     // Use injected file system executor or default
259 |     if (fileSystemExecutor) {
260 |       await fileSystemExecutor.mkdir(tempDir, { recursive: true });
261 |       await fileSystemExecutor.writeFile(logFilePath, '');
262 |     } else {
263 |       await fs.promises.mkdir(tempDir, { recursive: true });
264 |       await fs.promises.writeFile(logFilePath, '');
265 |     }
266 | 
267 |     logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
268 | 
269 |     logStream.write(
270 |       `\n--- Device log capture for bundle ID: ${bundleId} on device: ${deviceUuid} ---\n`,
271 |     );
272 | 
273 |     // Use executor with dependency injection instead of spawn directly
274 |     const result = await executor(
275 |       [
276 |         'xcrun',
277 |         'devicectl',
278 |         'device',
279 |         'process',
280 |         'launch',
281 |         '--console',
282 |         '--terminate-existing',
283 |         '--device',
284 |         deviceUuid,
285 |         '--json-output',
286 |         launchJsonPath,
287 |         bundleId,
288 |       ],
289 |       'Device Log Capture',
290 |       true,
291 |       undefined,
292 |       true,
293 |     );
294 | 
295 |     if (!result.success) {
296 |       log(
297 |         'error',
298 |         `Device log capture process reported failure: ${result.error ?? 'unknown error'}`,
299 |       );
300 |       if (logStream && !logStream.destroyed) {
301 |         logStream.write(
302 |           `\n--- Device log capture failed to start ---\n${result.error ?? 'Unknown error'}\n`,
303 |         );
304 |         logStream.end();
305 |       }
306 |       return {
307 |         sessionId: '',
308 |         error: result.error ?? 'Failed to start device log capture',
309 |       };
310 |     }
311 | 
312 |     const childProcess = result.process;
313 |     if (!childProcess) {
314 |       throw new Error('Device log capture process handle was not returned');
315 |     }
316 | 
317 |     const session: DeviceLogSession = {
318 |       process: childProcess,
319 |       logFilePath,
320 |       deviceUuid,
321 |       bundleId,
322 |       logStream,
323 |       hasEnded: false,
324 |     };
325 | 
326 |     let bufferedOutput = '';
327 |     const appendBufferedOutput = (text: string): void => {
328 |       bufferedOutput += text;
329 |       if (bufferedOutput.length > INITIAL_OUTPUT_LIMIT) {
330 |         bufferedOutput = bufferedOutput.slice(bufferedOutput.length - INITIAL_OUTPUT_LIMIT);
331 |       }
332 |     };
333 | 
334 |     let triggerImmediateFailure: ((message: string) => void) | undefined;
335 | 
336 |     const handleOutput = (chunk: unknown): void => {
337 |       if (!logStream || logStream.destroyed) return;
338 |       const text =
339 |         typeof chunk === 'string'
340 |           ? chunk
341 |           : chunk instanceof Buffer
342 |             ? chunk.toString('utf8')
343 |             : String(chunk ?? '');
344 |       if (text.length > 0) {
345 |         appendBufferedOutput(text);
346 |         const extracted = extractFailureMessage(bufferedOutput);
347 |         if (extracted) {
348 |           triggerImmediateFailure?.(extracted);
349 |         }
350 |         logStream.write(text);
351 |       }
352 |     };
353 | 
354 |     childProcess.stdout?.setEncoding?.('utf8');
355 |     childProcess.stdout?.on?.('data', handleOutput);
356 |     childProcess.stderr?.setEncoding?.('utf8');
357 |     childProcess.stderr?.on?.('data', handleOutput);
358 | 
359 |     const cleanupStreams = (): void => {
360 |       childProcess.stdout?.off?.('data', handleOutput);
361 |       childProcess.stderr?.off?.('data', handleOutput);
362 |     };
363 | 
364 |     const earlyFailure = await detectEarlyLaunchFailure(
365 |       childProcess,
366 |       EARLY_FAILURE_WINDOW_MS,
367 |       () => bufferedOutput,
368 |       (handler) => {
369 |         triggerImmediateFailure = handler;
370 |       },
371 |     );
372 | 
373 |     if (earlyFailure) {
374 |       cleanupStreams();
375 |       session.hasEnded = true;
376 | 
377 |       const failureMessage =
378 |         earlyFailure.errorMessage && earlyFailure.errorMessage.length > 0
379 |           ? earlyFailure.errorMessage
380 |           : `Device log capture process exited immediately (exit code: ${
381 |               earlyFailure.exitCode ?? 'unknown'
382 |             })`;
383 | 
384 |       log('error', `Device log capture failed to start: ${failureMessage}`);
385 |       if (logStream && !logStream.destroyed) {
386 |         try {
387 |           logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`);
388 |         } catch {
389 |           // best-effort logging
390 |         }
391 |         logStream.end();
392 |       }
393 | 
394 |       await removeFileIfExists(launchJsonPath, fileSystemExecutor);
395 | 
396 |       childProcess.kill?.('SIGTERM');
397 |       return { sessionId: '', error: failureMessage };
398 |     }
399 | 
400 |     const jsonOutcome = await pollJsonOutcome(
401 |       launchJsonPath,
402 |       fileSystemExecutor,
403 |       getJsonResultWaitMs(),
404 |     );
405 | 
406 |     if (jsonOutcome?.errorMessage) {
407 |       cleanupStreams();
408 |       session.hasEnded = true;
409 | 
410 |       const failureMessage = jsonOutcome.errorMessage;
411 | 
412 |       log('error', `Device log capture failed to start (JSON): ${failureMessage}`);
413 | 
414 |       if (logStream && !logStream.destroyed) {
415 |         try {
416 |           logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`);
417 |         } catch {
418 |           // ignore secondary logging failures
419 |         }
420 |         logStream.end();
421 |       }
422 | 
423 |       childProcess.kill?.('SIGTERM');
424 |       return { sessionId: '', error: failureMessage };
425 |     }
426 | 
427 |     if (jsonOutcome?.pid && logStream && !logStream.destroyed) {
428 |       try {
429 |         logStream.write(`Process ID: ${jsonOutcome.pid}\n`);
430 |       } catch {
431 |         // best-effort logging only
432 |       }
433 |     }
434 | 
435 |     childProcess.once?.('error', (err) => {
436 |       log(
437 |         'error',
438 |         `Device log capture process error (session ${logSessionId}): ${
439 |           err instanceof Error ? err.message : String(err)
440 |         }`,
441 |       );
442 |     });
443 | 
444 |     childProcess.once?.('close', (code) => {
445 |       cleanupStreams();
446 |       session.hasEnded = true;
447 |       if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) {
448 |         logStream.write(`\n--- Device log capture ended (exit code: ${code ?? 'unknown'}) ---\n`);
449 |         logStream.end();
450 |       }
451 |       void removeFileIfExists(launchJsonPath, fileSystemExecutor);
452 |     });
453 | 
454 |     // For testing purposes, we'll simulate process management
455 |     // In actual usage, the process would be managed by the executor result
456 |     activeDeviceLogSessions.set(logSessionId, session);
457 | 
458 |     log('info', `Device log capture started with session ID: ${logSessionId}`);
459 |     return { sessionId: logSessionId };
460 |   } catch (error) {
461 |     const message = error instanceof Error ? error.message : String(error);
462 |     log('error', `Failed to start device log capture: ${message}`);
463 |     if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) {
464 |       try {
465 |         logStream.write(`\n--- Device log capture failed: ${message} ---\n`);
466 |       } catch {
467 |         // ignore secondary stream write failures
468 |       }
469 |       logStream.end();
470 |     }
471 |     await removeFileIfExists(launchJsonPath, fileSystemExecutor);
472 |     return { sessionId: '', error: message };
473 |   }
474 | }
475 | 
476 | type EarlyFailureResult = {
477 |   exitCode: number | null;
478 |   errorMessage?: string;
479 | };
480 | 
481 | function detectEarlyLaunchFailure(
482 |   process: ChildProcess,
483 |   timeoutMs: number,
484 |   getBufferedOutput?: () => string,
485 |   registerImmediateFailure?: (handler: (message: string) => void) => void,
486 | ): Promise<EarlyFailureResult | null> {
487 |   if (process.exitCode != null) {
488 |     if (process.exitCode === 0) {
489 |       const failureFromOutput = extractFailureMessage(getBufferedOutput?.());
490 |       return Promise.resolve(
491 |         failureFromOutput ? { exitCode: process.exitCode, errorMessage: failureFromOutput } : null,
492 |       );
493 |     }
494 |     const failureFromOutput = extractFailureMessage(getBufferedOutput?.());
495 |     return Promise.resolve({ exitCode: process.exitCode, errorMessage: failureFromOutput });
496 |   }
497 | 
498 |   return new Promise<EarlyFailureResult | null>((resolve) => {
499 |     let settled = false;
500 | 
501 |     const finalize = (result: EarlyFailureResult | null): void => {
502 |       if (settled) return;
503 |       settled = true;
504 |       process.removeListener('close', onClose);
505 |       process.removeListener('error', onError);
506 |       clearTimeout(timer);
507 |       resolve(result);
508 |     };
509 | 
510 |     registerImmediateFailure?.((message) => {
511 |       finalize({ exitCode: process.exitCode ?? null, errorMessage: message });
512 |     });
513 | 
514 |     const onClose = (code: number | null): void => {
515 |       const failureFromOutput = extractFailureMessage(getBufferedOutput?.());
516 |       if (code === 0 && failureFromOutput) {
517 |         finalize({ exitCode: code ?? null, errorMessage: failureFromOutput });
518 |         return;
519 |       }
520 |       if (code === 0) {
521 |         finalize(null);
522 |       } else {
523 |         finalize({ exitCode: code ?? null, errorMessage: failureFromOutput });
524 |       }
525 |     };
526 | 
527 |     const onError = (error: Error): void => {
528 |       finalize({ exitCode: null, errorMessage: error.message });
529 |     };
530 | 
531 |     const timer = setTimeout(() => {
532 |       const failureFromOutput = extractFailureMessage(getBufferedOutput?.());
533 |       if (failureFromOutput) {
534 |         process.kill?.('SIGTERM');
535 |         finalize({ exitCode: process.exitCode ?? null, errorMessage: failureFromOutput });
536 |         return;
537 |       }
538 |       finalize(null);
539 |     }, timeoutMs);
540 | 
541 |     process.once('close', onClose);
542 |     process.once('error', onError);
543 |   });
544 | }
545 | 
546 | function extractFailureMessage(output?: string): string | undefined {
547 |   if (!output) {
548 |     return undefined;
549 |   }
550 |   const normalized = output.replace(/\r/g, '');
551 |   const lines = normalized
552 |     .split('\n')
553 |     .map((line) => line.trim())
554 |     .filter(Boolean);
555 | 
556 |   const shouldInclude = (line?: string): boolean => {
557 |     if (!line) return false;
558 |     return (
559 |       line.startsWith('NS') ||
560 |       line.startsWith('BundleIdentifier') ||
561 |       line.startsWith('Provide ') ||
562 |       line.startsWith('The application') ||
563 |       line.startsWith('ERROR:')
564 |     );
565 |   };
566 | 
567 |   for (const pattern of FAILURE_PATTERNS) {
568 |     const matchIndex = lines.findIndex((line) => pattern.test(line));
569 |     if (matchIndex === -1) {
570 |       continue;
571 |     }
572 | 
573 |     const snippet: string[] = [lines[matchIndex]];
574 |     const nextLine = lines[matchIndex + 1];
575 |     const thirdLine = lines[matchIndex + 2];
576 |     if (shouldInclude(nextLine)) snippet.push(nextLine);
577 |     if (shouldInclude(thirdLine)) snippet.push(thirdLine);
578 |     const message = snippet.join('\n').trim();
579 |     if (message.length > 0) {
580 |       return message;
581 |     }
582 |     return lines[matchIndex];
583 |   }
584 | 
585 |   return undefined;
586 | }
587 | 
588 | /**
589 |  * Deletes device log files older than LOG_RETENTION_DAYS from the temp directory.
590 |  * Runs quietly; errors are logged but do not throw.
591 |  */
592 | // Device logs follow the same retention policy as simulator logs but use a different prefix
593 | // to avoid conflicts. Both clean up logs older than LOG_RETENTION_DAYS automatically.
594 | async function cleanOldDeviceLogs(): Promise<void> {
595 |   const tempDir = os.tmpdir();
596 |   let files;
597 |   try {
598 |     files = await fs.promises.readdir(tempDir);
599 |   } catch (err) {
600 |     log(
601 |       'warn',
602 |       `Could not read temp dir for device log cleanup: ${err instanceof Error ? err.message : String(err)}`,
603 |     );
604 |     return;
605 |   }
606 |   const now = Date.now();
607 |   const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
608 |   await Promise.all(
609 |     files
610 |       .filter((f) => f.startsWith(DEVICE_LOG_FILE_PREFIX) && f.endsWith('.log'))
611 |       .map(async (f) => {
612 |         const filePath = path.join(tempDir, f);
613 |         try {
614 |           const stat = await fs.promises.stat(filePath);
615 |           if (now - stat.mtimeMs > retentionMs) {
616 |             await fs.promises.unlink(filePath);
617 |             log('info', `Deleted old device log file: ${filePath}`);
618 |           }
619 |         } catch (err) {
620 |           log(
621 |             'warn',
622 |             `Error during device log cleanup for ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
623 |           );
624 |         }
625 |       }),
626 |   );
627 | }
628 | 
629 | // Define schema as ZodObject
630 | const startDeviceLogCapSchema = z.object({
631 |   deviceId: z.string().describe('UDID of the device (obtained from list_devices)'),
632 |   bundleId: z.string().describe('Bundle identifier of the app to launch and capture logs for.'),
633 | });
634 | 
635 | // Use z.infer for type safety
636 | type StartDeviceLogCapParams = z.infer<typeof startDeviceLogCapSchema>;
637 | 
638 | /**
639 |  * Core business logic for starting device log capture.
640 |  */
641 | export async function start_device_log_capLogic(
642 |   params: StartDeviceLogCapParams,
643 |   executor: CommandExecutor,
644 |   fileSystemExecutor?: FileSystemExecutor,
645 | ): Promise<ToolResponse> {
646 |   const { deviceId, bundleId } = params;
647 | 
648 |   const { sessionId, error } = await startDeviceLogCapture(
649 |     {
650 |       deviceUuid: deviceId,
651 |       bundleId: bundleId,
652 |     },
653 |     executor,
654 |     fileSystemExecutor,
655 |   );
656 | 
657 |   if (error) {
658 |     return {
659 |       content: [
660 |         {
661 |           type: 'text',
662 |           text: `Failed to start device log capture: ${error}`,
663 |         },
664 |       ],
665 |       isError: true,
666 |     };
667 |   }
668 | 
669 |   return {
670 |     content: [
671 |       {
672 |         type: 'text',
673 |         text: `✅ Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\n\nNext Steps:\n1. Interact with your app on the device\n2. Use stop_device_log_cap({ logSessionId: '${sessionId}' }) to stop capture and retrieve logs`,
674 |       },
675 |     ],
676 |   };
677 | }
678 | 
679 | export default {
680 |   name: 'start_device_log_cap',
681 |   description: 'Starts log capture on a connected device.',
682 |   schema: startDeviceLogCapSchema.omit({ deviceId: true } as const).shape,
683 |   handler: createSessionAwareTool<StartDeviceLogCapParams>({
684 |     internalSchema: startDeviceLogCapSchema as unknown as z.ZodType<StartDeviceLogCapParams>,
685 |     logicFunction: start_device_log_capLogic,
686 |     getExecutor: getDefaultCommandExecutor,
687 |     requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }],
688 |   }),
689 | };
690 | 
```
Page 12/14FirstPrevNextLast