diff --git a/.github/workflows/tool-authoring-guidance.yml b/.github/workflows/tool-authoring-guidance.yml deleted file mode 100644 index 74c494268..000000000 --- a/.github/workflows/tool-authoring-guidance.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Tool Authoring Guidance - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - issues: write - pull-requests: read - -jobs: - comment: - name: Comment when tool contracts change - runs-on: ubuntu-latest - - steps: - - name: Post contributor guidance - uses: actions/github-script@v7 - with: - script: | - const marker = ''; - const watchedPrefixes = [ - 'manifests/', - 'schemas/', - 'src/mcp/tools/', - ]; - - const { owner, repo } = context.repo; - const pull_number = context.payload.pull_request.number; - - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, - repo, - pull_number, - per_page: 100, - }); - - const changedFiles = files - .map((file) => file.filename) - .filter((filename) => watchedPrefixes.some((prefix) => filename.startsWith(prefix))) - .sort(); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: pull_number, - per_page: 100, - }); - - const existingComment = comments.find((comment) => - comment.user?.type === 'Bot' && comment.body?.includes(marker) - ); - - if (changedFiles.length === 0) { - if (existingComment) { - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: existingComment.id, - }); - } - return; - } - - const maxFilesToShow = 30; - const shownFiles = changedFiles.slice(0, maxFilesToShow); - const hiddenCount = changedFiles.length - shownFiles.length; - const fileList = shownFiles.map((filename) => `- \`${filename}\``).join('\n'); - const hiddenText = hiddenCount > 0 ? `\n- ...and ${hiddenCount} more` : ''; - - const body = `${marker} - ## Tool authoring reminder - - This PR modifies tool contract files: - - ${fileList}${hiddenText} - - Please review the [Tool Authoring guide](https://xcodebuildmcp.com/docs/tool-authoring) before merging. - - Checklist: - - Run \`npm run test:snapshots\` for any added, modified, or deleted tool. - - If fixtures need to change, regenerate them with \`npm run test:snapshots:update\` and review the diff. - - Add, update, or remove the matching MCP, CLI, and JSON fixtures for the changed tool surface. - - Run \`npm run test:schema-fixtures\` after changing structured output schemas or JSON fixtures. - - Keep tool manifests, workflow manifests, output schemas, structured content, and fixtures aligned. - - If you changed tool metadata, run \`npm run docs:check\`. - - Snapshot tests are intentionally not a required PR gate because they are slow and environment-sensitive. This reminder exists so contributors know when they need to run them locally for tool additions, changes, and removals.`; - - const normalizedBody = body - .split('\n') - .map((line) => line.trimStart()) - .join('\n'); - - if (existingComment) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existingComment.id, - body: normalizedBody, - }); - return; - } - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pull_number, - body: normalizedBody, - }); diff --git a/AGENTS.md b/AGENTS.md index a0e45a7a8..2a29f48bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ Use these sections under `## [Unreleased]`: - Common hang causes: locked physical device, stale simulator state, `devicectl diagnose` waiting for password, orphaned daemon process. - Capture what you find before killing, so the root cause can be fixed rather than papered over. - If physical-device snapshot tests hang after the final test summary, the likely cause is Apple post-failure diagnostics invoking `devicectl diagnose`, which may prompt for a macOS password and wedge in automated runs. +- When asked to review changes or test failures, focus on regressions: behavior changes caused by the branch. Do not treat known/acceptable test flakes, environment setup issues, or nondeterministic tool output churn as regressions unless explicitly asked to investigate them. ## **CRITICAL** Tool Usage Rules **CRITICAL** - NEVER use sed/cat to read a file or a range of a file. Always use the native read tool. diff --git a/CHANGELOG.md b/CHANGELOG.md index f657851aa..7b74603ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### Added - Added `xcodebuildmcp upgrade` command to check for updates and upgrade in place. Supports `--check` (report-only) and `--yes`/`-y` (skip confirmation). Detects install method (Homebrew, npm-global, npx) and queries the appropriate channel source (`brew info`, `npm view`, or GitHub Releases) for the latest version. Non-interactive environments exit 1 when an auto-upgrade is possible but `--yes` was not supplied. +- Added platform selection step to the `xcodebuildmcp setup` wizard. You now choose which platforms you are developing for (macOS, iOS, tvOS, watchOS, visionOS) before selecting workflows. Based on the selection, the wizard automatically recommends the appropriate workflow set ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). + +### Changed + +- The `setup` wizard no longer prompts for a simulator or device when macOS is the only selected platform — macOS apps run natively and do not require a simulator or physical device ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). +- When a single platform is selected, `xcodebuildmcp setup` now writes `platform` to `sessionDefaults` in `config.yaml` and includes `XCODEBUILDMCP_PLATFORM` in `--format mcp-json` output. For multi-platform projects the platform key is omitted so the agent can choose per-command ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). +- The `setup` wizard remembers previous choices on re-run: existing `config.yaml` values (including the new `platform`) are pre-loaded as defaults for every prompt ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). ## [2.3.2] diff --git a/CLAUDE.md b/CLAUDE.md index 74a52bc62..40ab6744f 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,7 @@ Use these sections under `## [Unreleased]`: - Do NOT just kill the run — first inspect the process tree (`ps -ef | grep -E "vitest|xcodebuild|simctl|devicectl"`) to identify what's stuck. - Common hang causes: locked physical device, stale simulator state, `devicectl diagnose` waiting for password, orphaned daemon process. - Capture what you find before killing, so the root cause can be fixed rather than papered over. +- When asked to review changes or test failures, focus on regressions: behavior changes caused by the branch. Do not treat known/acceptable test flakes, environment setup issues, or nondeterministic tool output churn as regressions unless explicitly asked to investigate them. ## **CRITICAL** Tool Usage Rules **CRITICAL** - NEVER use sed/cat to read a file or a range of a file. Always use the native read tool. diff --git a/manifests/workflows/coverage.yaml b/manifests/workflows/coverage.yaml index f3ee6116e..ccd736a55 100644 --- a/manifests/workflows/coverage.yaml +++ b/manifests/workflows/coverage.yaml @@ -1,6 +1,7 @@ id: coverage title: Code Coverage description: View code coverage data from xcresult bundles produced by test runs. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] tools: - get_coverage_report - get_file_coverage diff --git a/manifests/workflows/debugging.yaml b/manifests/workflows/debugging.yaml index d3a92819e..f57eb1ae3 100644 --- a/manifests/workflows/debugging.yaml +++ b/manifests/workflows/debugging.yaml @@ -1,6 +1,7 @@ id: debugging title: LLDB Debugging description: Attach LLDB debugger to simulator apps, set breakpoints, inspect variables and call stacks. +targetPlatforms: [iOS, tvOS, watchOS, visionOS] tools: - debug_attach_sim - debug_breakpoint_add diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml index abeef9e74..ed97f2afa 100644 --- a/manifests/workflows/device.yaml +++ b/manifests/workflows/device.yaml @@ -1,6 +1,7 @@ id: device -title: iOS Device Development -description: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). +title: Device Development +description: Complete development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). +targetPlatforms: [iOS, tvOS, watchOS, visionOS] tools: - build_device - build_run_device diff --git a/manifests/workflows/doctor.yaml b/manifests/workflows/doctor.yaml index cc2b18794..649f86933 100644 --- a/manifests/workflows/doctor.yaml +++ b/manifests/workflows/doctor.yaml @@ -1,6 +1,7 @@ id: doctor title: MCP Doctor description: Diagnostic tool providing comprehensive information about the MCP server environment, dependencies, and configuration. +targetPlatforms: [] selection: mcp: autoInclude: true diff --git a/manifests/workflows/macos.yaml b/manifests/workflows/macos.yaml index 489d70767..0f600bca9 100644 --- a/manifests/workflows/macos.yaml +++ b/manifests/workflows/macos.yaml @@ -1,6 +1,7 @@ id: macos title: macOS Development description: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. +targetPlatforms: [macOS] tools: - build_macos - build_run_macos diff --git a/manifests/workflows/project-discovery.yaml b/manifests/workflows/project-discovery.yaml index 279a74bae..00f800161 100644 --- a/manifests/workflows/project-discovery.yaml +++ b/manifests/workflows/project-discovery.yaml @@ -1,6 +1,7 @@ id: project-discovery title: Project Discovery description: Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] tools: - discover_projs - list_schemes diff --git a/manifests/workflows/project-scaffolding.yaml b/manifests/workflows/project-scaffolding.yaml index 2ffdf8cb7..137ce2e64 100644 --- a/manifests/workflows/project-scaffolding.yaml +++ b/manifests/workflows/project-scaffolding.yaml @@ -1,6 +1,7 @@ id: project-scaffolding title: Project Scaffolding description: Scaffold new iOS and macOS projects from templates. +targetPlatforms: [iOS, macOS] tools: - scaffold_ios_project - scaffold_macos_project diff --git a/manifests/workflows/session-management.yaml b/manifests/workflows/session-management.yaml index 0c2469176..e28e34e58 100644 --- a/manifests/workflows/session-management.yaml +++ b/manifests/workflows/session-management.yaml @@ -1,6 +1,7 @@ id: session-management title: Session Management description: Manage session defaults for project/workspace paths, scheme, configuration, simulator/device settings. +targetPlatforms: [] availability: cli: false selection: diff --git a/manifests/workflows/simulator-management.yaml b/manifests/workflows/simulator-management.yaml index ca55174e3..0f7814727 100644 --- a/manifests/workflows/simulator-management.yaml +++ b/manifests/workflows/simulator-management.yaml @@ -1,6 +1,7 @@ id: simulator-management title: Simulator Management description: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. +targetPlatforms: [iOS, tvOS, watchOS, visionOS] tools: - boot_sim - list_sims diff --git a/manifests/workflows/simulator.yaml b/manifests/workflows/simulator.yaml index cd70c6a2d..636e2b077 100644 --- a/manifests/workflows/simulator.yaml +++ b/manifests/workflows/simulator.yaml @@ -1,6 +1,7 @@ id: simulator title: iOS Simulator Development description: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. +targetPlatforms: [iOS, tvOS, watchOS, visionOS] selection: mcp: defaultEnabled: true diff --git a/manifests/workflows/swift-package.yaml b/manifests/workflows/swift-package.yaml index 856e3a67e..640ab3637 100644 --- a/manifests/workflows/swift-package.yaml +++ b/manifests/workflows/swift-package.yaml @@ -1,6 +1,7 @@ id: swift-package title: Swift Package Development description: Build, test, run and manage Swift Package Manager projects. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] tools: - swift_package_build - swift_package_test diff --git a/manifests/workflows/ui-automation.yaml b/manifests/workflows/ui-automation.yaml index 9f471b3e1..c11e5dd72 100644 --- a/manifests/workflows/ui-automation.yaml +++ b/manifests/workflows/ui-automation.yaml @@ -1,6 +1,7 @@ id: ui-automation title: UI Automation description: UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows. +targetPlatforms: [iOS] tools: - tap - touch diff --git a/manifests/workflows/utilities.yaml b/manifests/workflows/utilities.yaml index dfcc802cf..01fd713f9 100644 --- a/manifests/workflows/utilities.yaml +++ b/manifests/workflows/utilities.yaml @@ -1,5 +1,6 @@ id: utilities title: Build Utilities description: Utility tools for cleaning build products and managing build artifacts. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] tools: - clean diff --git a/manifests/workflows/workflow-discovery.yaml b/manifests/workflows/workflow-discovery.yaml index 099034f6d..4c82eec64 100644 --- a/manifests/workflows/workflow-discovery.yaml +++ b/manifests/workflows/workflow-discovery.yaml @@ -1,6 +1,7 @@ id: workflow-discovery title: Workflow Discovery description: Manage enabled workflows at runtime. +targetPlatforms: [] availability: cli: false selection: diff --git a/manifests/workflows/xcode-ide.yaml b/manifests/workflows/xcode-ide.yaml index b9295e7a3..46c0d2ffe 100644 --- a/manifests/workflows/xcode-ide.yaml +++ b/manifests/workflows/xcode-ide.yaml @@ -1,6 +1,7 @@ id: xcode-ide title: Xcode IDE Integration description: Bridge tools for connecting to Xcode's built-in MCP server (mcpbridge) to access IDE-specific functionality. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] availability: cli: true predicates: diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index ac8f8a7fc..c86265026 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -108,6 +108,26 @@ function createTestPrompter(): Prompter { }; } +function createPlatformPrompter(platforms: string[]): Prompter { + let selectManyCalls = 0; + return { + selectOne: async (opts: { options: Array<{ value: T }> }) => { + const preferredOption = opts.options.find((option) => option.value != null); + return (preferredOption ?? opts.options[0]).value; + }, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + selectManyCalls++; + if (selectManyCalls === 1) { + return opts.options + .filter((option) => platforms.includes(String(option.value))) + .map((option) => option.value); + } + return opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; +} + describe('setup command', () => { const originalStdinIsTTY = process.stdin.isTTY; const originalStdoutIsTTY = process.stdout.isTTY; @@ -253,7 +273,8 @@ describe('setup command', () => { offeredWorkflowIds = opts.options.map((option) => String(option.value)); return opts.options.map((option) => option.value); }, - confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + confirm: async (opts: { defaultValue: boolean; message: string }) => + opts.message === 'Show additional workflows?' ? true : opts.defaultValue, }; await runSetupWizard({ @@ -273,6 +294,223 @@ describe('setup command', () => { expect(offeredWorkflowIds).toContain('doctor'); }); + async function getWorkflowPromptStateForPlatforms( + selectedPlatforms: string[], + opts?: { showAdditionalWorkflows?: boolean; storedConfig?: string }, + ): Promise<{ + additionalWorkflowIds: string[]; + flatWorkflowIds: string[]; + recommendedInitialKeys: string[]; + recommendedWorkflowIds: string[]; + }> { + const { fs } = createSetupFs({ storedConfig: opts?.storedConfig }); + let additionalWorkflowIds: string[] = []; + let flatWorkflowIds: string[] = []; + let recommendedInitialKeys: string[] = []; + let recommendedWorkflowIds: string[] = []; + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15', + udid: 'SIM-1', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + }); + } + + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: true, + output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-1) (Shutdown)`, + }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + let selectManyCalls = 0; + const prompter: Prompter = { + selectOne: async (selectOpts: { options: Array<{ value: T }> }) => { + const preferredOption = selectOpts.options.find((option) => option.value != null); + return (preferredOption ?? selectOpts.options[0]).value; + }, + selectMany: async (selectOpts: { + options: Array<{ value: T }>; + initialSelectedKeys?: ReadonlySet; + getKey: (value: T) => string; + }) => { + selectManyCalls++; + if (selectManyCalls === 1) { + return selectOpts.options + .filter((option) => selectedPlatforms.includes(String(option.value))) + .map((option) => option.value); + } + + const workflowIds = selectOpts.options + .map((option) => selectOpts.getKey(option.value)) + .sort(); + + if (opts?.storedConfig != null) { + flatWorkflowIds = workflowIds; + return selectOpts.options + .filter((option) => + selectOpts.initialSelectedKeys?.has(selectOpts.getKey(option.value)), + ) + .map((option) => option.value); + } + + if (selectManyCalls === 2) { + recommendedInitialKeys = [ + ...(selectOpts.initialSelectedKeys ?? new Set()), + ].sort(); + recommendedWorkflowIds = workflowIds; + return selectOpts.options + .filter((option) => recommendedInitialKeys.includes(selectOpts.getKey(option.value))) + .map((option) => option.value); + } + + additionalWorkflowIds = workflowIds; + return []; + }, + confirm: async (confirmOpts: { defaultValue: boolean; message: string }) => { + if (confirmOpts.message === 'Show additional workflows?') { + return opts?.showAdditionalWorkflows ?? false; + } + return confirmOpts.defaultValue; + }, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + return { + additionalWorkflowIds, + flatWorkflowIds, + recommendedInitialKeys, + recommendedWorkflowIds, + }; + } + + it('shows iOS workflows as recommended options with only simulator selected by default', async () => { + const state = await getWorkflowPromptStateForPlatforms(['iOS']); + + expect(state.recommendedInitialKeys).toEqual(['simulator']); + expect(state.recommendedWorkflowIds).toEqual([ + 'coverage', + 'debugging', + 'device', + 'project-discovery', + 'project-scaffolding', + 'simulator', + 'simulator-management', + 'swift-package', + 'ui-automation', + 'utilities', + 'xcode-ide', + ]); + expect(state.recommendedWorkflowIds).not.toContain('macos'); + expect(state.recommendedWorkflowIds).not.toContain('doctor'); + expect(state.recommendedWorkflowIds).not.toContain('session-management'); + expect(state.recommendedWorkflowIds).not.toContain('workflow-discovery'); + }); + + it('shows macOS workflows as recommended options with only macos selected by default', async () => { + const state = await getWorkflowPromptStateForPlatforms(['macOS']); + + expect(state.recommendedInitialKeys).toEqual(['macos']); + expect(state.recommendedWorkflowIds).toEqual([ + 'coverage', + 'macos', + 'project-discovery', + 'project-scaffolding', + 'swift-package', + 'utilities', + 'xcode-ide', + ]); + expect(state.recommendedWorkflowIds).not.toContain('debugging'); + expect(state.recommendedWorkflowIds).not.toContain('device'); + expect(state.recommendedWorkflowIds).not.toContain('simulator'); + expect(state.recommendedWorkflowIds).not.toContain('simulator-management'); + expect(state.recommendedWorkflowIds).not.toContain('ui-automation'); + }); + + it('shows tvOS workflows as recommended options with only simulator selected by default', async () => { + const state = await getWorkflowPromptStateForPlatforms(['tvOS']); + + expect(state.recommendedInitialKeys).toEqual(['simulator']); + expect(state.recommendedWorkflowIds).toEqual([ + 'coverage', + 'debugging', + 'device', + 'project-discovery', + 'simulator', + 'simulator-management', + 'swift-package', + 'utilities', + 'xcode-ide', + ]); + expect(state.recommendedWorkflowIds).not.toContain('macos'); + expect(state.recommendedWorkflowIds).not.toContain('project-scaffolding'); + expect(state.recommendedWorkflowIds).not.toContain('ui-automation'); + }); + + it('selects macos and simulator by default for mixed macOS and simulator platforms', async () => { + const state = await getWorkflowPromptStateForPlatforms(['macOS', 'iOS']); + + expect(state.recommendedInitialKeys).toEqual(['macos', 'simulator']); + expect(state.recommendedWorkflowIds).toContain('macos'); + expect(state.recommendedWorkflowIds).toContain('simulator'); + }); + + it('does not recommend ui-automation for visionOS from manifest target platform metadata', async () => { + const state = await getWorkflowPromptStateForPlatforms(['visionOS']); + + expect(state.recommendedInitialKeys).toEqual(['simulator']); + expect(state.recommendedWorkflowIds).not.toContain('ui-automation'); + }); + + it('shows non-recommended workflows only after the user asks for additional options', async () => { + const state = await getWorkflowPromptStateForPlatforms(['iOS'], { + showAdditionalWorkflows: true, + }); + + expect(state.recommendedWorkflowIds).toContain('simulator'); + expect(state.recommendedWorkflowIds).toContain('xcode-ide'); + expect(state.additionalWorkflowIds).toContain('macos'); + expect(state.additionalWorkflowIds).not.toContain('simulator'); + expect(state.additionalWorkflowIds).not.toContain('xcode-ide'); + }); + + it('uses the normal flat workflow list when loading an existing config', async () => { + const state = await getWorkflowPromptStateForPlatforms(['iOS'], { + storedConfig: 'schemaVersion: 1\nenabledWorkflows:\n - simulator\n', + }); + + expect(state.flatWorkflowIds).toContain('simulator'); + expect(state.flatWorkflowIds).toContain('macos'); + expect(state.flatWorkflowIds).toContain('xcode-ide'); + expect(state.recommendedWorkflowIds).toEqual([]); + expect(state.additionalWorkflowIds).toEqual([]); + }); + it('fails fast when Xcode command line tools are unavailable', async () => { const failingExecutor: CommandExecutor = async (command) => { if (command[0] === 'xcodebuild') { @@ -1054,4 +1292,593 @@ sessionDefaults: await expect(runSetupWizard()).rejects.toThrow('requires an interactive TTY'); }); + + it('skips simulator and sets platform for macOS-only selection', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['macOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + setupPreferences?: { platforms?: string[] }; + }; + + expect(parsed.setupPreferences?.platforms).toEqual(['macOS']); + expect(parsed.sessionDefaults?.platform).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('outputs XCODEBUILDMCP_PLATFORM=macOS and no simulator fields for macOS-only mcp-json', async () => { + const fs = createMockFileSystemExecutor({ + existsSync: () => false, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async () => '', + writeFile: async () => {}, + }); + + const executor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + + const result = await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['macOS']), + quietOutput: true, + outputFormat: 'mcp-json', + }); + + expect(result.mcpConfigJson).toBeDefined(); + const parsed = JSON.parse(result.mcpConfigJson!) as { + mcpServers: { XcodeBuildMCP: { env: Record } }; + }; + const env = parsed.mcpServers.XcodeBuildMCP.env; + + expect(env.XCODEBUILDMCP_PLATFORM).toBe('macOS'); + expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBeUndefined(); + expect(env.XCODEBUILDMCP_SIMULATOR_NAME).toBeUndefined(); + }); + + it('outputs XCODEBUILDMCP_PLATFORM=iOS Simulator and simulator fields for iOS-only mcp-json', async () => { + const fs = createMockFileSystemExecutor({ + existsSync: () => false, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async () => '', + writeFile: async () => {}, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'SIM-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: true, + output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-1) (Shutdown)`, + }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const result = await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['iOS']), + quietOutput: true, + outputFormat: 'mcp-json', + }); + + expect(result.mcpConfigJson).toBeDefined(); + const parsed = JSON.parse(result.mcpConfigJson!) as { + mcpServers: { XcodeBuildMCP: { env: Record } }; + }; + const env = parsed.mcpServers.XcodeBuildMCP.env; + + expect(env.XCODEBUILDMCP_PLATFORM).toBe('iOS Simulator'); + expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1'); + expect(env.XCODEBUILDMCP_SIMULATOR_NAME).toBe('iPhone 15'); + }); + + it('omits XCODEBUILDMCP_PLATFORM for multi-platform mcp-json', async () => { + const fs = createMockFileSystemExecutor({ + existsSync: () => false, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async () => '', + writeFile: async () => {}, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'SIM-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: true, + output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-1) (Shutdown)`, + }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const result = await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['macOS', 'iOS']), + quietOutput: true, + outputFormat: 'mcp-json', + }); + + expect(result.mcpConfigJson).toBeDefined(); + const parsed = JSON.parse(result.mcpConfigJson!) as { + mcpServers: { XcodeBuildMCP: { env: Record } }; + }; + const env = parsed.mcpServers.XcodeBuildMCP.env; + + expect(env.XCODEBUILDMCP_PLATFORM).toBeUndefined(); + expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1'); + }); + + it('clears stale deviceId, simulatorId, and simulatorName for macOS-only re-runs', async () => { + let storedConfig = [ + 'enabledWorkflows:', + ' - simulator', + ' - logging', + 'sessionDefaults:', + ' scheme: App', + ' workspacePath: ./App.xcworkspace', + ' deviceId: STALE-DEVICE', + ' simulatorId: STALE-SIM', + ' simulatorName: Old iPhone', + ' platform: iOS Simulator', + '', + ].join('\n'); + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['macOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect( + (parsed as { setupPreferences?: { platforms?: string[] } }).setupPreferences?.platforms, + ).toEqual(['macOS']); + // setup intentionally does not touch sessionDefaults.platform (agent-controlled field); + // the pre-existing value from the fixture is preserved. + expect(parsed.sessionDefaults?.platform).toBe('iOS Simulator'); + expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('persists platform=tvOS Simulator and a tvOS-runtime simulator for tvOS-only YAML setup', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'IOS-1', state: 'Shutdown', isAvailable: true }, + ], + 'tvOS 17.0': [ + { name: 'Apple TV 4K', udid: 'TVOS-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ success: true, output: '' }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['tvOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect( + (parsed as { setupPreferences?: { platforms?: string[] } }).setupPreferences?.platforms, + ).toEqual(['tvOS']); + expect(parsed.sessionDefaults?.platform).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBe('TVOS-1'); + expect(parsed.sessionDefaults?.simulatorName).toBe('Apple TV 4K'); + }); + + it('persists platform=watchOS Simulator and a watchOS-runtime simulator for watchOS-only YAML setup', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'IOS-1', state: 'Shutdown', isAvailable: true }, + ], + 'watchOS 10.0': [ + { + name: 'Apple Watch Series 9', + udid: 'WATCH-1', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ success: true, output: '' }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['watchOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect( + (parsed as { setupPreferences?: { platforms?: string[] } }).setupPreferences?.platforms, + ).toEqual(['watchOS']); + expect(parsed.sessionDefaults?.platform).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBe('WATCH-1'); + expect(parsed.sessionDefaults?.simulatorName).toBe('Apple Watch Series 9'); + }); + + it('persists platform=visionOS Simulator and an xrOS-runtime simulator for visionOS-only YAML setup', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'IOS-1', state: 'Shutdown', isAvailable: true }, + ], + 'xrOS 1.0': [ + { name: 'Apple Vision Pro', udid: 'XROS-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ success: true, output: '' }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['visionOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect( + (parsed as { setupPreferences?: { platforms?: string[] } }).setupPreferences?.platforms, + ).toEqual(['visionOS']); + expect(parsed.sessionDefaults?.platform).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBe('XROS-1'); + expect(parsed.sessionDefaults?.simulatorName).toBe('Apple Vision Pro'); + }); + + it('matches a SimRuntime-style visionOS runtime via the xrOS keyword', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { name: 'iPhone 15', udid: 'IOS-1', state: 'Shutdown', isAvailable: true }, + ], + 'com.apple.CoreSimulator.SimRuntime.xrOS-1-0': [ + { name: 'Apple Vision Pro', udid: 'XROS-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ success: true, output: '' }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['visionOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.simulatorId).toBe('XROS-1'); + }); }); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 3bc2aab16..3de1f6631 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -6,6 +6,7 @@ import { discoverProjects } from '../../mcp/tools/project-discovery/discover_pro import { listSchemes } from '../../mcp/tools/project-discovery/list_schemes.ts'; import { listSimulators, type ListedSimulator } from '../../mcp/tools/simulator/list_sims.ts'; import { loadManifest, type WorkflowManifestEntry } from '../../core/manifest/load-manifest.ts'; +import type { WorkflowTargetPlatform } from '../../core/manifest/schema.ts'; import { isWorkflowEnabledForRuntime } from '../../visibility/exposure.ts'; import { getConfig } from '../../utils/config-store.ts'; import { @@ -23,11 +24,30 @@ import { import type { FileSystemExecutor } from '../../utils/FileSystemExecutor.ts'; import type { CommandExecutor } from '../../utils/CommandExecutor.ts'; import { createDoctorDependencies } from '../../mcp/tools/doctor/lib/doctor.deps.ts'; +import { XcodePlatform } from '../../types/common.ts'; + +type SetupPlatform = WorkflowTargetPlatform; + +const SETUP_PLATFORM_TO_SESSION_DEFAULT: Record = { + macOS: XcodePlatform.macOS, + iOS: XcodePlatform.iOSSimulator, + tvOS: XcodePlatform.tvOSSimulator, + watchOS: XcodePlatform.watchOSSimulator, + visionOS: XcodePlatform.visionOSSimulator, +}; + +const SIMULATOR_RUNTIME_KEYWORDS: Record, string[]> = { + iOS: ['iOS'], + tvOS: ['tvOS'], + watchOS: ['watchOS'], + visionOS: ['visionOS', 'xrOS'], +}; interface SetupSelection { debug: boolean; sentryDisabled: boolean; enabledWorkflows: string[]; + platforms: SetupPlatform[]; projectPath?: string; workspacePath?: string; scheme: string; @@ -65,6 +85,22 @@ interface SetupDevice { platform: string; } +const PLATFORM_OPTIONS: Array<{ value: SetupPlatform; label: string; description: string }> = [ + { value: 'macOS', label: 'macOS', description: 'Native macOS apps — no simulator needed' }, + { value: 'iOS', label: 'iOS', description: 'iPhone and iPad apps, runs on iOS Simulator' }, + { value: 'tvOS', label: 'tvOS', description: 'Apple TV apps, runs on tvOS Simulator' }, + { + value: 'watchOS', + label: 'watchOS', + description: 'Apple Watch apps, runs on watchOS Simulator', + }, + { + value: 'visionOS', + label: 'visionOS', + description: 'Apple Vision Pro apps, runs on visionOS Simulator', + }, +]; + function showPromptHelp(helpText: string, quietOutput: boolean): void { if (quietOutput) { return; @@ -136,6 +172,43 @@ function normalizeExistingDefaults(config?: ProjectConfig): { }; } +function inferPlatformsFromExisting(config?: ProjectConfig): SetupPlatform[] { + if (!config) return []; + + const stored = config.setupPreferences?.platforms; + if (stored && stored.length > 0) { + return [...stored]; + } + + // No stored preference: only macOS is unambiguously recoverable from enabledWorkflows. + // Simulator-platform identity (iOS vs tvOS vs watchOS vs visionOS) cannot be inferred + // from workflow ids alone, so leave it blank and let the wizard re-prompt. + const workflows = new Set(config.enabledWorkflows ?? []); + return workflows.has('macos') ? ['macOS'] : []; +} + +function derivePlatformSessionDefault(platforms: SetupPlatform[]): string | undefined { + if (platforms.length !== 1) return undefined; + return SETUP_PLATFORM_TO_SESSION_DEFAULT[platforms[0]]; +} + +function filterSimulatorsByPlatforms( + simulators: ListedSimulator[], + platforms: SetupPlatform[], +): ListedSimulator[] { + const nonMacPlatforms = platforms.filter((p) => p !== 'macOS') as Exclude< + SetupPlatform, + 'macOS' + >[]; + if (nonMacPlatforms.length !== 1) return simulators; + + const keywords = SIMULATOR_RUNTIME_KEYWORDS[nonMacPlatforms[0]]; + const filtered = simulators.filter((sim) => + keywords.some((keyword) => sim.runtime.includes(keyword)), + ); + return filtered.length > 0 ? filtered : simulators; +} + function getWorkflowOptions( debug: boolean, existingConfig?: ProjectConfig, @@ -159,6 +232,54 @@ function getWorkflowOptions( .sort((left, right) => left.id.localeCompare(right.id)); } +function getRecommendedWorkflowIds( + workflows: WorkflowManifestEntry[], + platforms: SetupPlatform[], +): string[] { + const selectedPlatforms = new Set(platforms); + return workflows + .filter((workflow) => + workflow.targetPlatforms.some((platform) => selectedPlatforms.has(platform)), + ) + .map((workflow) => workflow.id); +} + +function getDefaultWorkflowIdsForPlatforms( + workflows: WorkflowManifestEntry[], + platforms: SetupPlatform[], +): string[] { + const availableIds = new Set(workflows.map((workflow) => workflow.id)); + const defaults: string[] = []; + + if (platforms.includes('macOS') && availableIds.has('macos')) { + defaults.push('macos'); + } + + if (platforms.some((platform) => platform !== 'macOS') && availableIds.has('simulator')) { + defaults.push('simulator'); + } + + return defaults; +} + +function toWorkflowSelectOptions(workflows: WorkflowManifestEntry[]): SelectOption[] { + return workflows.map((workflow) => ({ + value: workflow.id, + label: workflow.id, + description: workflow.description, + })); +} + +function mergeWorkflowSelections( + workflowOptions: SelectOption[], + selectedIds: Iterable, +): string[] { + const selected = new Set(selectedIds); + return workflowOptions + .filter((option) => selected.has(option.value)) + .map((option) => option.value); +} + function getChangedFields( beforeConfig: ProjectConfig | undefined, afterConfig: ProjectConfig, @@ -208,6 +329,11 @@ function getChangedFields( beforeValue: beforeDefaults.simulatorName, afterValue: afterDefaults.simulatorName, }, + { + label: 'setupPreferences.platforms', + beforeValue: beforeConfig?.setupPreferences?.platforms, + afterValue: afterConfig.setupPreferences?.platforms, + }, ]; const changed: string[] = []; @@ -226,6 +352,7 @@ async function selectWorkflowIds(opts: { debug: boolean; existingConfig?: ProjectConfig; existingEnabledWorkflows: string[]; + platforms: SetupPlatform[]; prompter: Prompter; quietOutput: boolean; }): Promise { @@ -234,28 +361,91 @@ async function selectWorkflowIds(opts: { return []; } - const workflowOptions: SelectOption[] = workflows.map((workflow) => ({ - value: workflow.id, - label: workflow.id, - description: workflow.description, - })); - + const recommendedIds = new Set(getRecommendedWorkflowIds(workflows, opts.platforms)); + const workflowOptions = toWorkflowSelectOptions(workflows); const defaults = - opts.existingEnabledWorkflows.length > 0 ? opts.existingEnabledWorkflows : ['simulator']; + opts.existingEnabledWorkflows.length > 0 + ? opts.existingEnabledWorkflows + : getDefaultWorkflowIdsForPlatforms(workflows, opts.platforms); + + if (opts.existingEnabledWorkflows.length > 0 || recommendedIds.size === 0) { + showPromptHelp( + 'Select workflows to choose which groups of tools are enabled by default in this project.', + opts.quietOutput, + ); + return opts.prompter.selectMany({ + message: 'Select workflows to enable', + options: workflowOptions, + initialSelectedKeys: new Set(defaults), + getKey: (value) => value, + minSelected: 1, + }); + } + + const recommendedOptions = workflowOptions.filter((option) => recommendedIds.has(option.value)); + const otherOptions = workflowOptions.filter((option) => !recommendedIds.has(option.value)); showPromptHelp( - 'Select workflows to choose which groups of tools are enabled by default in this project.', + 'Recommended workflows are based on your selected platform(s).\n' + + 'Only the core default workflow is selected automatically; you can adjust the recommendation list freely.', opts.quietOutput, ); - const selected = await opts.prompter.selectMany({ - message: 'Select workflows to enable', - options: workflowOptions, + const selectedRecommended = await opts.prompter.selectMany({ + message: 'Select recommended workflows to enable', + options: recommendedOptions, initialSelectedKeys: new Set(defaults), getKey: (value) => value, - minSelected: 1, + minSelected: otherOptions.length > 0 ? 0 : 1, + }); + + if (otherOptions.length === 0) { + return selectedRecommended; + } + + showPromptHelp( + 'Additional workflows are not specifically recommended for your selected platform(s),\n' + + 'but you can still enable them if they fit your project.', + opts.quietOutput, + ); + const showAdditionalWorkflows = + selectedRecommended.length === 0 || + (await opts.prompter.confirm({ + message: 'Show additional workflows?', + defaultValue: false, + })); + + if (!showAdditionalWorkflows) { + return selectedRecommended; + } + + const selectedOther = await opts.prompter.selectMany({ + message: 'Select additional workflows to enable', + options: otherOptions, + getKey: (value) => value, + minSelected: selectedRecommended.length === 0 ? 1 : 0, }); - return selected; + return mergeWorkflowSelections(workflowOptions, [...selectedRecommended, ...selectedOther]); +} + +async function selectPlatforms(opts: { + existingPlatforms: SetupPlatform[]; + prompter: Prompter; + quietOutput: boolean; +}): Promise { + const defaults = opts.existingPlatforms.length > 0 ? opts.existingPlatforms : ['iOS']; + showPromptHelp( + 'Select which platforms you are developing for. This determines which workflows are\n' + + 'recommended and whether a simulator needs to be configured.', + opts.quietOutput, + ); + return opts.prompter.selectMany({ + message: 'Select target platforms', + options: PLATFORM_OPTIONS, + initialSelectedKeys: new Set(defaults), + getKey: (value) => value, + minSelected: 1, + }); } type ProjectChoice = { kind: 'workspace' | 'project'; absolutePath: string }; @@ -369,12 +559,13 @@ function getDefaultSimulatorIndex( async function selectSimulator(opts: { existingSimulatorId?: string; existingSimulatorName?: string; + platformFilter: SetupPlatform[]; executor: CommandExecutor; prompter: Prompter; isTTY: boolean; quietOutput: boolean; }): Promise { - const simulators = await withSpinner({ + const allSimulators = await withSpinner({ isTTY: opts.isTTY, quietOutput: opts.quietOutput, startMessage: 'Loading simulators...', @@ -387,6 +578,7 @@ async function selectSimulator(opts: { } }, }); + const simulators = filterSimulatorsByPlatforms(allSimulators, opts.platformFilter); const defaultIndex = simulators.length > 0 @@ -692,10 +884,17 @@ async function collectSetupSelection( defaultValue: existingConfig?.sentryDisabled ?? false, }); + const platforms = await selectPlatforms({ + existingPlatforms: inferPlatformsFromExisting(existingConfig), + prompter: deps.prompter, + quietOutput: deps.quietOutput, + }); + const enabledWorkflows = await selectWorkflowIds({ debug, existingConfig, existingEnabledWorkflows: existingConfig?.enabledWorkflows ?? [], + platforms, prompter: deps.prompter, quietOutput: deps.quietOutput, }); @@ -721,40 +920,47 @@ async function collectSetupSelection( quietOutput: deps.quietOutput, }); - const simulator = requiresSimulatorDefault(enabledWorkflows) - ? await selectSimulator({ - existingSimulatorId: existing.simulatorId, - existingSimulatorName: existing.simulatorName, - executor: deps.executor, - prompter: deps.prompter, - isTTY, - quietOutput: deps.quietOutput, - }) - : undefined; - - const device = requiresDeviceDefault(enabledWorkflows) - ? await selectDevice({ - existingDeviceId: existing.deviceId, - fs: deps.fs, - executor: deps.executor, - prompter: deps.prompter, - isTTY, - quietOutput: deps.quietOutput, - }) - : undefined; + const isMacOsOnly = platforms.length > 0 && platforms.every((p) => p === 'macOS'); + + const simulator = + !isMacOsOnly && requiresSimulatorDefault(enabledWorkflows) + ? await selectSimulator({ + existingSimulatorId: existing.simulatorId, + existingSimulatorName: existing.simulatorName, + platformFilter: platforms, + executor: deps.executor, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }) + : undefined; + + const device = + !isMacOsOnly && requiresDeviceDefault(enabledWorkflows) + ? await selectDevice({ + existingDeviceId: existing.deviceId, + fs: deps.fs, + executor: deps.executor, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }) + : undefined; return { debug, sentryDisabled, enabledWorkflows, + platforms, projectPath: projectChoice.kind === 'project' ? projectChoice.absolutePath : undefined, workspacePath: projectChoice.kind === 'workspace' ? projectChoice.absolutePath : undefined, scheme, deviceId: device?.udid, simulatorId: simulator?.udid, simulatorName: simulator?.name, - clearDeviceDefault: requiresDeviceDefault(enabledWorkflows) && device == null, - clearSimulatorDefault: requiresSimulatorDefault(enabledWorkflows) && simulator == null, + clearDeviceDefault: isMacOsOnly || (requiresDeviceDefault(enabledWorkflows) && device == null), + clearSimulatorDefault: + isMacOsOnly || (requiresSimulatorDefault(enabledWorkflows) && simulator == null), }; } @@ -783,6 +989,12 @@ function selectionToMcpConfigJson(selection: SetupSelection): string { if (selection.deviceId) { env.XCODEBUILDMCP_DEVICE_ID = selection.deviceId; } + + const derivedPlatform = derivePlatformSessionDefault(selection.platforms); + if (derivedPlatform) { + env.XCODEBUILDMCP_PLATFORM = derivedPlatform; + } + if (selection.simulatorId) { env.XCODEBUILDMCP_SIMULATOR_ID = selection.simulatorId; } @@ -825,18 +1037,20 @@ export async function runSetupWizard(deps?: Partial): Promise if (isMcpJson) { clack.log.info( 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + - 'You will select a project or workspace, scheme, and any\n' + - 'simulator/device defaults required by the workflows you enable.\n' + - 'A bootstrap MCP config JSON block for\n' + - 'clients with limited workspace support will be printed at the end.', + 'You will select target platforms, workflows, a project or workspace,\n' + + 'scheme, and any simulator/device defaults required by the workflows\n' + + 'you enable. A ready-to-paste MCP config JSON block will be printed\n' + + 'at the end. You can rerun this wizard at any time — previous choices\n' + + 'are pre-loaded automatically.', ); } else { clack.log.info( 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + - 'You will select a project or workspace, scheme, and any\n' + - 'simulator/device defaults required by the workflows you enable.\n' + - 'Settings are saved to\n' + - '.xcodebuildmcp/config.yaml in your project directory.', + 'You will select target platforms, workflows, a project or workspace,\n' + + 'scheme, and any simulator/device defaults required by the workflows\n' + + 'you enable. Settings are saved to .xcodebuildmcp/config.yaml in your\n' + + 'project directory. You can rerun this wizard at any time — previous\n' + + 'choices are pre-loaded automatically.', ); } } @@ -906,6 +1120,8 @@ export async function runSetupWizard(deps?: Partial): Promise simulatorId: selection.simulatorId, simulatorName: selection.simulatorName, }, + setupPreferences: + selection.platforms.length > 0 ? { platforms: [...selection.platforms] } : null, }, deleteSessionDefaultKeys, }); diff --git a/src/core/manifest/__tests__/schema.test.ts b/src/core/manifest/__tests__/schema.test.ts index 177bb6e70..528339bd3 100644 --- a/src/core/manifest/__tests__/schema.test.ts +++ b/src/core/manifest/__tests__/schema.test.ts @@ -17,6 +17,7 @@ describe('schema', () => { id: 'simulator', title: 'iOS Simulator Development', description: 'Build and test iOS apps on simulators', + targetPlatforms: ['iOS'], tools: ['build_sim'], }; @@ -36,10 +37,48 @@ describe('schema', () => { expect(toolResult.data.predicates).toEqual([]); expect(workflowResult.data.availability).toEqual({ mcp: true, cli: true }); expect(workflowResult.data.predicates).toEqual([]); + expect(workflowResult.data.targetPlatforms).toEqual(['iOS']); expect(workflowResult.data.tools).toEqual(['build_sim']); expect(getEffectiveCliName(toolResult.data)).toBe('build-sim'); }); + it('requires workflow target platform metadata', () => { + const result = workflowManifestEntrySchema.safeParse({ + id: 'simulator', + title: 'iOS Simulator Development', + description: 'Build and test iOS apps on simulators', + tools: ['build_sim'], + }); + + expect(result.success).toBe(false); + }); + + it('rejects invalid workflow target platform metadata', () => { + const result = workflowManifestEntrySchema.safeParse({ + id: 'simulator', + title: 'iOS Simulator Development', + description: 'Build and test iOS apps on simulators', + targetPlatforms: ['iPhoneOS'], + tools: ['build_sim'], + }); + + expect(result.success).toBe(false); + }); + + it('allows empty workflow target platform metadata', () => { + const result = workflowManifestEntrySchema.safeParse({ + id: 'workflow-discovery', + title: 'Workflow Discovery', + description: 'Manage enabled workflows at runtime', + targetPlatforms: [], + tools: ['manage_workflows'], + }); + + expect(result.success).toBe(true); + if (!result.success) throw new Error('Expected empty targetPlatforms to parse'); + expect(result.data.targetPlatforms).toEqual([]); + }); + it('parses output schema metadata for tool manifests', () => { const result = toolManifestEntrySchema.safeParse({ id: 'list_sims', diff --git a/src/core/manifest/schema.ts b/src/core/manifest/schema.ts index 2f4730dca..d75be3219 100644 --- a/src/core/manifest/schema.ts +++ b/src/core/manifest/schema.ts @@ -140,6 +140,13 @@ export const workflowSelectionSchema = z.object({ export type WorkflowSelection = z.infer; +/** + * Apple platforms used by setup to recommend workflows. + */ +export const workflowTargetPlatformSchema = z.enum(['iOS', 'macOS', 'tvOS', 'watchOS', 'visionOS']); + +export type WorkflowTargetPlatform = z.infer; + /** * Workflow manifest entry schema. * Describes a workflow's metadata and tool composition. @@ -154,6 +161,9 @@ export const workflowManifestEntrySchema = z.object({ /** Workflow description */ description: z.string(), + /** Setup platforms this workflow is recommended for */ + targetPlatforms: z.array(workflowTargetPlatformSchema), + /** Per-runtime availability flags */ availability: availabilitySchema.default({ mcp: true, cli: true }), diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index 41b25741b..1532be260 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -5,6 +5,12 @@ import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { expectPendingBuildResponse, runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { schema, handler, buildDeviceLogic } from '../build_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import type { CommandExecutor } from '../../../../utils/execution/index.ts'; + +const runHandlerWithExecutor = handler as unknown as ( + args: Record, + executor: CommandExecutor, +) => Promise<{ isError?: boolean }>; function createSpyExecutor(): { commandCalls: Array<{ args: string[]; logPrefix?: string }>; @@ -41,8 +47,12 @@ describe('build_device plugin', () => { false, ); + expect(schemaObj.safeParse({ platform: 'tvOS' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'macOS' }).success).toBe(false); + const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs']); + expect(schemaKeys).toEqual(['extraArgs', 'platform']); }); }); @@ -202,6 +212,39 @@ describe('build_device plugin', () => { expect(spy.commandCalls[0].logPrefix).toBe('iOS Device Build'); }); + it('should build for a selected non-iOS device platform', async () => { + const spy = createSpyExecutor(); + + sessionStore.setDefaults({ + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }); + + const result = await runHandlerWithExecutor({ platform: 'tvOS' }, spy.executor); + + expect(result.isError).toBeUndefined(); + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toContain('generic/platform=tvOS'); + expect(spy.commandCalls[0].logPrefix).toBe('tvOS Device Build'); + }); + + it('should normalize simulator session platforms for device builds', async () => { + const spy = createSpyExecutor(); + + sessionStore.setDefaults({ + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + platform: 'tvOS Simulator', + }); + + const result = await runHandlerWithExecutor({}, spy.executor); + + expect(result.isError).toBeUndefined(); + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toContain('generic/platform=tvOS'); + expect(spy.commandCalls[0].logPrefix).toBe('tvOS Device Build'); + }); + it('should return exact successful build response', async () => { const mockExecutor = createMockExecutor({ success: true, diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts index a9e0f60f5..3b7664b35 100644 --- a/src/mcp/tools/device/__tests__/build_run_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -33,12 +33,15 @@ describe('build_run_device tool', () => { expect(schemaObj.safeParse({}).success).toBe(true); expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true); expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'macOS' }).success).toBe(false); expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false); expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['env', 'extraArgs']); + expect(schemaKeys).toEqual(['env', 'extraArgs', 'platform']); }); }); diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index acfde74e9..d28dd821c 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -24,16 +24,18 @@ describe('get_device_app_path plugin', () => { expect(typeof handler).toBe('function'); }); - it('should expose empty public schema', () => { + it('should expose only session-free fields in public schema', () => { const schemaObj = z.strictObject(schema); expect(schemaObj.safeParse({}).success).toBe(true); - expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(false); + expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'macOS' }).success).toBe(false); expect(schemaObj.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe( false, ); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual([]); + expect(schemaKeys).toEqual(['platform']); }); }); diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index a737ccf14..974c560dd 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -59,13 +59,15 @@ describe('test_device plugin', () => { expect(schemaObj.safeParse({}).success).toBe(true); expect(schemaObj.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); expect(schemaObj.safeParse({ preferXcodebuild: true }).success).toBe(false); - expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(false); + expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'macOS' }).success).toBe(false); expect(schemaObj.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe( false, ); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs', 'progress', 'testRunnerEnv']); + expect(schemaKeys).toEqual(['extraArgs', 'platform', 'progress', 'testRunnerEnv']); }); it('should validate XOR between projectPath and workspacePath', async () => { diff --git a/src/mcp/tools/device/build-settings.ts b/src/mcp/tools/device/build-settings.ts index f4213a05a..cb7883bb3 100644 --- a/src/mcp/tools/device/build-settings.ts +++ b/src/mcp/tools/device/build-settings.ts @@ -1,6 +1,28 @@ +import * as z from 'zod'; import { XcodePlatform } from '../../../types/common.ts'; -export type DevicePlatform = 'iOS' | 'watchOS' | 'tvOS' | 'visionOS'; +const devicePlatformValues = ['iOS', 'watchOS', 'tvOS', 'visionOS'] as const; + +export type DevicePlatform = (typeof devicePlatformValues)[number]; + +function normalizeDevicePlatform(platform?: unknown): unknown { + switch (platform) { + case XcodePlatform.iOSSimulator: + return 'iOS'; + case XcodePlatform.watchOSSimulator: + return 'watchOS'; + case XcodePlatform.tvOSSimulator: + return 'tvOS'; + case XcodePlatform.visionOSSimulator: + return 'visionOS'; + default: + return platform; + } +} + +export const devicePlatformSchema = z + .preprocess(normalizeDevicePlatform, z.enum(devicePlatformValues).optional()) + .describe('Device platform: iOS, watchOS, tvOS, or visionOS. Defaults to iOS.'); export function mapDevicePlatform(platform?: DevicePlatform): XcodePlatform { switch (platform) { @@ -12,7 +34,6 @@ export function mapDevicePlatform(platform?: DevicePlatform): XcodePlatform { return XcodePlatform.visionOS; case 'iOS': case undefined: - default: return XcodePlatform.iOS; } } diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index d7f2bf09f..eed277795 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -8,8 +8,8 @@ import * as z from 'zod'; import type { BuildResultDomainResult } from '../../../types/domain-results.ts'; import type { StreamingExecutor } from '../../../types/tool-execution.ts'; -import { XcodePlatform } from '../../../types/common.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import { devicePlatformSchema, mapDevicePlatform } from './build-settings.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { @@ -35,7 +35,7 @@ function createBuildDeviceRequest(params: BuildDeviceParams): BuildInvocationReq workspacePath: params.workspacePath, projectPath: params.projectPath, configuration: params.configuration ?? 'Debug', - platform: 'iOS', + platform: String(mapDevicePlatform(params.platform)), target: 'device', }; } @@ -48,6 +48,7 @@ const baseSchemaObject = z.object({ derivedDataPath: z.string().optional(), extraArgs: z.array(z.string()).optional(), preferXcodebuild: z.boolean().optional(), + platform: devicePlatformSchema, }); const buildDeviceSchema = z.preprocess( @@ -71,6 +72,7 @@ export function createBuildDeviceExecutor( executor: CommandExecutor, ): StreamingExecutor { return async (params, ctx) => { + const platform = mapDevicePlatform(params.platform); const processedParams = { ...params, configuration: params.configuration ?? 'Debug', @@ -80,8 +82,8 @@ export function createBuildDeviceExecutor( const buildResult = await executeXcodeBuildCommand( processedParams, { - platform: XcodePlatform.iOS, - logPrefix: 'iOS Device Build', + platform, + logPrefix: `${platform} Device Build`, }, params.preferXcodebuild ?? false, 'build', diff --git a/src/mcp/tools/device/build_run_device.ts b/src/mcp/tools/device/build_run_device.ts index 6fc9eb59f..cde8768b6 100644 --- a/src/mcp/tools/device/build_run_device.ts +++ b/src/mcp/tools/device/build_run_device.ts @@ -23,7 +23,7 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings, withProjectOrWorkspace } from '../../../utils/schema-helpers.ts'; import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; -import { mapDevicePlatform } from './build-settings.ts'; +import { devicePlatformSchema, mapDevicePlatform } from './build-settings.ts'; import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; import { installAppOnDevice, launchAppOnDevice } from '../../../utils/device-steps.ts'; import type { BuildInvocationRequest } from '../../../types/domain-fragments.ts'; @@ -53,7 +53,7 @@ const baseSchemaObject = z.object({ workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), scheme: z.string().describe('The scheme to build and run'), deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), - platform: z.enum(['iOS', 'watchOS', 'tvOS', 'visionOS']).optional().describe('default: iOS'), + platform: devicePlatformSchema, configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), derivedDataPath: z.string().optional(), extraArgs: z.array(z.string()).optional(), @@ -269,7 +269,6 @@ const publicSchemaObject = baseSchemaObject.omit({ workspacePath: true, scheme: true, deviceId: true, - platform: true, configuration: true, derivedDataPath: true, preferXcodebuild: true, diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 6dfe93456..e8f47c8b1 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -18,7 +18,7 @@ import { toInternalSchema, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings, withProjectOrWorkspace } from '../../../utils/schema-helpers.ts'; -import { mapDevicePlatform } from './build-settings.ts'; +import { devicePlatformSchema, mapDevicePlatform } from './build-settings.ts'; import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; import { toErrorMessage } from '../../../utils/errors.ts'; import { @@ -32,7 +32,7 @@ import { const baseOptions = { scheme: z.string().describe('The scheme to use'), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), - platform: z.enum(['iOS', 'watchOS', 'tvOS', 'visionOS']).optional().describe('default: iOS'), + platform: devicePlatformSchema, }; const baseSchemaObject = z.object({ @@ -53,7 +53,6 @@ const publicSchemaObject = baseSchemaObject.omit({ workspacePath: true, scheme: true, configuration: true, - platform: true, } as const); function createRequest(params: GetDeviceAppPathParams) { diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index e41879f60..2e66c0558 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -9,6 +9,7 @@ import * as z from 'zod'; import type { TestResultDomainResult } from '../../../types/domain-results.ts'; import type { StreamingExecutor } from '../../../types/tool-execution.ts'; import { XcodePlatform } from '../../../types/common.ts'; +import { devicePlatformSchema, mapDevicePlatform } from './build-settings.ts'; import { createTestExecutor } from '../../../utils/test/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { @@ -39,7 +40,7 @@ const baseSchemaObject = z.object({ derivedDataPath: z.string().optional(), extraArgs: z.array(z.string()).optional(), preferXcodebuild: z.boolean().optional(), - platform: z.enum(['iOS', 'watchOS', 'tvOS', 'visionOS']).optional(), + platform: devicePlatformSchema, testRunnerEnv: z .record(z.string(), z.string()) .optional() @@ -68,7 +69,6 @@ const publicSchemaObject = baseSchemaObject.omit({ configuration: true, derivedDataPath: true, preferXcodebuild: true, - platform: true, } as const); interface PreparedTestDeviceExecution { @@ -83,7 +83,7 @@ async function prepareTestDeviceExecution( fileSystemExecutor: FileSystemExecutor, ): Promise { const configuration = params.configuration ?? 'Debug'; - const platform = (params.platform as XcodePlatform) || XcodePlatform.iOS; + const platform = mapDevicePlatform(params.platform); const preflight = await resolveTestPreflight( { projectPath: params.projectPath, diff --git a/src/utils/__tests__/tool-registry.test.ts b/src/utils/__tests__/tool-registry.test.ts index 46569bb55..a740183f7 100644 --- a/src/utils/__tests__/tool-registry.test.ts +++ b/src/utils/__tests__/tool-registry.test.ts @@ -35,6 +35,7 @@ function createManifestFixture(): ResolvedManifest { id: 'simulator', title: 'Simulator', description: 'Built-in simulator workflow', + targetPlatforms: ['iOS'], availability: { mcp: true, cli: true }, predicates: [], tools: ['build_run_sim'], @@ -56,6 +57,7 @@ describe('createCustomWorkflowsFromConfig', () => { expect(result.workflows).toEqual([ expect.objectContaining({ id: 'my-workflow', + targetPlatforms: [], tools: ['build_run_sim', 'screenshot'], }), ]); diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index fb9af4952..0a61c588a 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -46,6 +46,10 @@ export type PersistActiveSessionDefaultsProfileOptions = { profile?: string | null; }; +export type SetupPreferences = { + platforms?: ('macOS' | 'iOS' | 'tvOS' | 'watchOS' | 'visionOS')[]; +}; + export type PersistProjectConfigPatchOptions = { fs: FileSystemExecutor; cwd: string; @@ -56,6 +60,7 @@ export type PersistProjectConfigPatchOptions = { experimentalWorkflowDiscovery?: boolean; disableSessionDefaults?: boolean; sessionDefaults?: Partial; + setupPreferences?: SetupPreferences | null; }; deleteSessionDefaultKeys?: (keyof SessionDefaults)[]; }; @@ -424,6 +429,12 @@ export async function persistProjectConfigPatch( nextConfig[key] = value; } + if (options.patch.setupPreferences === null) { + delete nextConfig.setupPreferences; + } else if (options.patch.setupPreferences !== undefined) { + nextConfig.setupPreferences = options.patch.setupPreferences; + } + if (options.patch.sessionDefaults) { const patch = removeUndefined(options.patch.sessionDefaults as Record); const nextSessionDefaults: Partial = { diff --git a/src/utils/runtime-config-schema.ts b/src/utils/runtime-config-schema.ts index ce0690c86..878164099 100644 --- a/src/utils/runtime-config-schema.ts +++ b/src/utils/runtime-config-schema.ts @@ -25,6 +25,11 @@ export const runtimeConfigFileSchema = z sessionDefaults: sessionDefaultsSchema.optional(), sessionDefaultsProfiles: z.record(z.string(), sessionDefaultsSchema).optional(), activeSessionDefaultsProfile: z.string().optional(), + setupPreferences: z + .object({ + platforms: z.array(z.enum(['macOS', 'iOS', 'tvOS', 'watchOS', 'visionOS'])).optional(), + }) + .optional(), }) .passthrough(); diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index c4722d6bd..ff39b981f 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -158,6 +158,7 @@ export function createCustomWorkflowsFromConfig( id: workflowName, title: workflowName, description: `Custom workflow '${workflowName}' from config.yaml.`, + targetPlatforms: [], availability: { mcp: true, cli: false }, selection: { mcp: { defaultEnabled: false, autoInclude: false } }, predicates: [], diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 3eedd2856..a2563d28e 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -62,6 +62,7 @@ function createWorkflow(overrides: Partial = {}): Workflo id: 'test-workflow', title: 'Test Workflow', description: 'A test workflow', + targetPlatforms: [], availability: { mcp: true, cli: true }, predicates: [], tools: ['test_tool'],