From b0204d72eb74ead477328258a70dcc76b71d8a41 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 27 Feb 2026 22:12:40 +0000 Subject: [PATCH 1/5] feat(cli): add interactive setup wizard and modernize init command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `xcodebuildmcp setup` — an interactive terminal wizard that walks users through configuring project defaults (project/workspace, scheme, simulator, workflows, debug mode, Sentry opt-out) and persists the result to .xcodebuildmcp/config.yaml. Key changes: - New setup command with clack-based interactive prompts - Shared Prompter abstraction for testable TTY/non-interactive prompts - Promote sentryDisabled from env-var-only to first-class config key - Extract reusable functions from discover_projs, list_schemes, list_sims so both MCP tools and CLI can call them directly - Modernize init command to use clack prompts and interactive selection - Replace Cursor/Codex client targets with generic Agents Skills target - Add persistProjectConfigPatch for atomic config file updates --- config.example.yaml | 2 + docs/CLI.md | 5 + docs/CONFIGURATION.md | 19 +- docs/GETTING_STARTED.md | 6 + docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- .../iOS_Calculator/.xcodebuildmcp/config.yaml | 6 +- package-lock.json | 29 +- package.json | 1 + scripts/check-docs-cli-commands.js | 2 +- src/cli.ts | 24 +- src/cli/commands/__tests__/init.test.ts | 55 +- src/cli/commands/__tests__/setup.test.ts | 164 +++++ src/cli/commands/init.ts | 276 +++++++-- src/cli/commands/setup.ts | 567 ++++++++++++++++++ src/cli/interactive/prompts.ts | 145 +++++ src/cli/yargs-app.ts | 2 + src/daemon.ts | 4 + .../__tests__/discover_projs.test.ts | 17 +- .../__tests__/list_schemes.test.ts | 18 +- .../tools/project-discovery/discover_projs.ts | 145 ++--- .../tools/project-discovery/list_schemes.ts | 68 ++- .../simulator/__tests__/list_sims.test.ts | 41 +- src/mcp/tools/simulator/list_sims.ts | 146 +++-- src/server/start-mcp-server.ts | 3 + src/utils/__tests__/config-store.test.ts | 11 + src/utils/__tests__/project-config.test.ts | 73 +++ src/utils/config-store.ts | 11 + src/utils/project-config.ts | 62 ++ src/utils/runtime-config-schema.ts | 1 + src/utils/sentry-config.ts | 21 + src/visibility/__tests__/exposure.test.ts | 1 + .../__tests__/predicate-registry.test.ts | 1 + 33 files changed, 1703 insertions(+), 227 deletions(-) create mode 100644 src/cli/commands/__tests__/setup.test.ts create mode 100644 src/cli/commands/setup.ts create mode 100644 src/cli/interactive/prompts.ts create mode 100644 src/utils/sentry-config.ts diff --git a/config.example.yaml b/config.example.yaml index 60df0c0a..63319638 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,6 +3,8 @@ enabledWorkflows: ['simulator', 'ui-automation', 'debugging'] experimentalWorkflowDiscovery: false disableSessionDefaults: false incrementalBuildsEnabled: false +debug: false +sentryDisabled: false sessionDefaults: projectPath: './MyApp.xcodeproj' # xor workspacePath workspacePath: './MyApp.xcworkspace' # xor projectPath diff --git a/docs/CLI.md b/docs/CLI.md index 51d227b3..576ff47a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -25,6 +25,9 @@ xcodebuildmcp --help # View tool help xcodebuildmcp --help + +# Run interactive setup for .xcodebuildmcp/config.yaml +xcodebuildmcp setup ``` ## Tool Options @@ -116,6 +119,8 @@ enabledWorkflows: See [CONFIGURATION.md](CONFIGURATION.md) for the full schema. +To create/update config interactively, run `xcodebuildmcp setup`. + ## Environment Variables | Variable | Description | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4adcf620..f9cc91d8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -25,6 +25,12 @@ Create a config file at your workspace root: /.xcodebuildmcp/config.yaml ``` +Or run the interactive setup wizard: + +```bash +xcodebuildmcp setup +``` + Minimal example: ```yaml @@ -61,6 +67,7 @@ incrementalBuildsEnabled: false # Debugging debug: false +sentryDisabled: false debuggerBackend: "dap" dapRequestTimeoutMs: 30000 dapLogEvents: false @@ -262,8 +269,13 @@ Default templates: By default, only internal XcodeBuildMCP runtime failures are sent to Sentry. User-domain errors (such as project build/test/config failures) are not sent. To disable telemetry entirely: ```yaml -# Environment variable only (no config.yaml option) -# XCODEBUILDMCP_SENTRY_DISABLED=true +sentryDisabled: true +``` + +You can also disable telemetry via environment variable: + +```bash +XCODEBUILDMCP_SENTRY_DISABLED=true ``` See [PRIVACY.md](PRIVACY.md) for more information. @@ -286,6 +298,7 @@ Notes: | `sessionDefaults` | object | `{}` | | `incrementalBuildsEnabled` | boolean | `false` | | `debug` | boolean | `false` | +| `sentryDisabled` | boolean | `false` | | `debuggerBackend` | string | `"dap"` | | `dapRequestTimeoutMs` | number | `30000` | | `dapLogEvents` | boolean | `false` | @@ -310,6 +323,7 @@ Environment variables are supported for backwards compatibility but the config f | `disableSessionDefaults` | `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` | | `incrementalBuildsEnabled` | `INCREMENTAL_BUILDS_ENABLED` | | `debug` | `XCODEBUILDMCP_DEBUG` | +| `sentryDisabled` | `XCODEBUILDMCP_SENTRY_DISABLED` | | `debuggerBackend` | `XCODEBUILDMCP_DEBUGGER_BACKEND` | | `dapRequestTimeoutMs` | `XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS` | | `dapLogEvents` | `XCODEBUILDMCP_DAP_LOG_EVENTS` | @@ -320,7 +334,6 @@ Environment variables are supported for backwards compatibility but the config f | `iosTemplateVersion` | `XCODEBUILD_MCP_IOS_TEMPLATE_VERSION` | | `macosTemplatePath` | `XCODEBUILDMCP_MACOS_TEMPLATE_PATH` | | `macosTemplateVersion` | `XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION` | -| (no config option) | `XCODEBUILDMCP_SENTRY_DISABLED` | Config file takes precedence over environment variables when both are set. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 511259c0..e3dafff0 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -69,6 +69,12 @@ For deterministic session defaults and runtime configuration, add a config file /.xcodebuildmcp/config.yaml ``` +Use the setup wizard to create or update this file interactively: + +```bash +xcodebuildmcp setup +``` + See [CONFIGURATION.md](CONFIGURATION.md) for the full schema and examples. ## Client-specific configuration diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index a806e763..95892641 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -189,4 +189,4 @@ XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-22T18:16:55.247Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-27T19:40:32.655Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index c2e13a0e..8ea500f8 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -205,4 +205,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-22T18:16:55.247Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-27T19:40:32.655Z UTC* diff --git a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml index 237b38ba..72b0293e 100644 --- a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml +++ b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml @@ -1,11 +1,11 @@ schemaVersion: 1 enabledWorkflows: + - debugging - simulator - ui-automation - - debugging - xcode-ide sessionDefaults: - workspacePath: ./CalculatorApp.xcworkspace + workspacePath: CalculatorApp.xcworkspace scheme: CalculatorApp configuration: Debug simulatorName: iPhone 17 Pro @@ -17,3 +17,5 @@ sessionDefaults: derivedDataPath: ./iOS_Calculator/.derivedData preferXcodebuild: true bundleId: io.sentry.calculatorapp +debug: false +sentryDisabled: false diff --git a/package-lock.json b/package-lock.json index 5a7d83a8..fbcc1b16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.1.0", "license": "MIT", "dependencies": { + "@clack/prompts": "^1.0.1", "@modelcontextprotocol/sdk": "^1.25.1", "@sentry/cli": "^3.1.0", "@sentry/node": "^10.38.0", @@ -161,6 +162,27 @@ "node": ">=18" } }, + "node_modules/@clack/core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.1.tgz", + "integrity": "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.1.tgz", + "integrity": "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.0.1", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -5217,7 +5239,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -5969,6 +5990,12 @@ "node": ">=18" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", diff --git a/package.json b/package.json index 6d39ece0..7257e5d5 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "url": "https://github.com/getsentry/XcodeBuildMCP/issues" }, "dependencies": { + "@clack/prompts": "^1.0.1", "@modelcontextprotocol/sdk": "^1.25.1", "@sentry/cli": "^3.1.0", "@sentry/node": "^10.38.0", diff --git a/scripts/check-docs-cli-commands.js b/scripts/check-docs-cli-commands.js index 197001df..20515a4a 100755 --- a/scripts/check-docs-cli-commands.js +++ b/scripts/check-docs-cli-commands.js @@ -125,7 +125,7 @@ function extractCommandCandidates(content) { } function findInvalidCommands(files, validPairs, validWorkflows) { - const validTopLevel = new Set(['mcp', 'tools', 'daemon', 'init']); + const validTopLevel = new Set(['mcp', 'tools', 'daemon', 'init', 'setup']); const validDaemonActions = new Set(['status', 'start', 'stop', 'restart', 'list']); const findings = []; diff --git a/src/cli.ts b/src/cli.ts index ea594bbe..57863edf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { startMcpServer } from './server/start-mcp-server.ts'; import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts'; import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts'; import { setLogLevel, type LogLevel } from './utils/logger.ts'; +import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts'; function findTopLevelCommand(argv: string[]): string | undefined { const flagsWithValue = new Set(['--socket', '--log-level', '--style']); @@ -31,12 +32,11 @@ function findTopLevelCommand(argv: string[]): string | undefined { return undefined; } -async function runInitCommand(): Promise { +async function buildLightweightYargsApp(): Promise> { const yargs = (await import('yargs')).default; const { hideBin } = await import('yargs/helpers'); - const { registerInitCommand } = await import('./cli/commands/init.ts'); - const app = yargs(hideBin(process.argv)) + return yargs(hideBin(process.argv)) .scriptName('') .strict() .help() @@ -63,10 +63,22 @@ async function runInitCommand(): Promise { setLogLevel(level); } }); +} + +async function runInitCommand(): Promise { + const { registerInitCommand } = await import('./cli/commands/init.ts'); + const app = await buildLightweightYargsApp(); registerInitCommand(app); await app.parseAsync(); } +async function runSetupCommand(): Promise { + const { registerSetupCommand } = await import('./cli/commands/setup.ts'); + const app = await buildLightweightYargsApp(); + registerSetupCommand(app); + await app.parseAsync(); +} + async function main(): Promise { const cliBootstrapStartedAt = Date.now(); const earlyCommand = findTopLevelCommand(process.argv.slice(2)); @@ -78,6 +90,12 @@ async function main(): Promise { await runInitCommand(); return; } + if (earlyCommand === 'setup') { + await runSetupCommand(); + return; + } + + await hydrateSentryDisabledEnvFromProjectConfig(); initSentry({ mode: 'cli' }); // CLI mode uses disableSessionDefaults to show all tool parameters as flags diff --git a/src/cli/commands/__tests__/init.test.ts b/src/cli/commands/__tests__/init.test.ts index 8c95203b..0c0114cb 100644 --- a/src/cli/commands/__tests__/init.test.ts +++ b/src/cli/commands/__tests__/init.test.ts @@ -126,6 +126,26 @@ describe('init command', () => { stdoutSpy.mockRestore(); }); + + it('expands ~ in --dest to home directory', async () => { + const fakeHome = join(tempDir, 'home'); + mkdirSync(fakeHome, { recursive: true }); + mockedHomedir.mockReturnValue(fakeHome); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', '~/skills', '--skill', 'cli']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + const installed = join(fakeHome, 'skills', 'xcodebuildmcp-cli', 'SKILL.md'); + expect(existsSync(installed)).toBe(true); + + stdoutSpy.mockRestore(); + }); }); describe('conflict handling', () => { @@ -167,7 +187,7 @@ describe('init command', () => { const app = yargs(['init', '--dest', dest, '--skill', 'cli']).scriptName('').fail(false); mod.registerInitCommand(app); - await expect(app.parseAsync()).rejects.toThrow('Conflicting skill'); + await expect(app.parseAsync()).rejects.toThrow('conflicting mcp skill found'); Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); }); @@ -313,8 +333,7 @@ describe('init command', () => { await app.parseAsync(); expect(existsSync(join(emptyHome, '.claude', 'skills'))).toBe(false); - expect(existsSync(join(emptyHome, '.cursor', 'skills'))).toBe(false); - expect(existsSync(join(emptyHome, '.codex', 'skills', 'public'))).toBe(false); + expect(existsSync(join(emptyHome, '.agents', 'skills'))).toBe(false); stdoutSpy.mockRestore(); }); @@ -371,7 +390,33 @@ describe('init command', () => { expect(readFileSync(join(conflictDir, 'SKILL.md'), 'utf8')).toBe('existing mcp skill'); }); - it('errors when no clients detected and no --dest or --print', async () => { + it('errors in non-interactive mode without --client or --dest', async () => { + const originalStdinIsTTY = process.stdin.isTTY; + const originalStdoutIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--skill', 'cli']).scriptName('').fail(false); + mod.registerInitCommand(app); + + await expect(app.parseAsync()).rejects.toThrow( + 'Non-interactive mode requires --client or --dest for init', + ); + + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + }); + }); + + it('errors when no clients detected with --client=auto and no --dest or --print', async () => { const emptyHome = join(tempDir, 'empty-home'); mkdirSync(emptyHome, { recursive: true }); mockedHomedir.mockReturnValue(emptyHome); @@ -379,7 +424,7 @@ describe('init command', () => { const yargs = (await import('yargs')).default; const mod = await loadInitModule(); - const app = yargs(['init', '--skill', 'cli']).scriptName('').fail(false); + const app = yargs(['init', '--skill', 'cli', '--client', 'auto']).scriptName('').fail(false); mod.registerInitCommand(app); await expect(app.parseAsync()).rejects.toThrow('No supported AI clients detected'); diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts new file mode 100644 index 00000000..e76c45ac --- /dev/null +++ b/src/cli/commands/__tests__/setup.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { + createMockCommandResponse, + createMockFileSystemExecutor, +} from '../../../test-utils/mock-executors.ts'; +import type { CommandExecutor } from '../../../utils/CommandExecutor.ts'; +import type { Prompter } from '../../interactive/prompts.ts'; +import { runSetupWizard } from '../setup.ts'; + +const cwd = '/repo'; +const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); + +function createTestPrompter(): Prompter { + return { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => + 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; + + beforeEach(() => { + process.argv = ['node', 'script', 'setup']; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + }); + }); + + it('exports a setup wizard that writes config selections', 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: '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: createTestPrompter(), + quietOutput: true, + }); + expect(result.configPath).toBe(configPath); + + const parsed = parseYaml(storedConfig) as { + debug?: boolean; + sentryDisabled?: boolean; + enabledWorkflows?: string[]; + sessionDefaults?: Record; + }; + + expect(parsed.enabledWorkflows?.length).toBeGreaterThan(0); + expect(parsed.debug).toBe(false); + expect(parsed.sentryDisabled).toBe(false); + expect(parsed.sessionDefaults?.workspacePath).toBe('App.xcworkspace'); + expect(parsed.sessionDefaults?.scheme).toBe('App'); + expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1'); + }); + + it('fails fast when Xcode command line tools are unavailable', async () => { + const failingExecutor: CommandExecutor = async (command) => { + if (command[0] === 'xcodebuild') { + return createMockCommandResponse({ + success: false, + output: '', + error: 'xcodebuild: command not found', + }); + } + + return createMockCommandResponse({ success: true, output: '' }); + }; + + await expect( + runSetupWizard({ + cwd, + fs: createMockFileSystemExecutor(), + executor: failingExecutor, + prompter: createTestPrompter(), + quietOutput: true, + }), + ).rejects.toThrow('Setup prerequisites failed'); + }); + + it('fails in non-interactive mode', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + await expect(runSetupWizard()).rejects.toThrow('requires an interactive TTY'); + }); +}); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index dcd3a27f..6a8874dd 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -2,8 +2,9 @@ import type { Argv } from 'yargs'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import * as readline from 'node:readline'; +import * as clack from '@clack/prompts'; import { getResourceRoot } from '../../core/resource-root.ts'; +import { createPrompter, type Prompter } from '../interactive/prompts.ts'; type SkillType = 'mcp' | 'cli'; @@ -15,14 +16,9 @@ interface ClientInfo { const CLIENT_DEFINITIONS: { id: string; name: string; skillsSubdir: string }[] = [ { id: 'claude', name: 'Claude Code', skillsSubdir: '.claude/skills' }, - { id: 'cursor', name: 'Cursor', skillsSubdir: '.cursor/skills' }, - { id: 'codex', name: 'Codex', skillsSubdir: '.codex/skills/public' }, + { id: 'agents', name: 'Agents Skills', skillsSubdir: '.agents/skills' }, ]; -function writeLine(text: string): void { - process.stdout.write(`${text}\n`); -} - function skillDirName(skillType: SkillType): string { return skillType === 'mcp' ? 'xcodebuildmcp' : 'xcodebuildmcp-cli'; } @@ -66,22 +62,42 @@ function readSkillContent(skillType: SkillType): string { return fs.readFileSync(sourcePath, 'utf8'); } -async function promptYesNo(question: string): Promise { - if (!process.stdin.isTTY) { +function expandHomePrefix(inputPath: string): string { + if (inputPath === '~') { + return os.homedir(); + } + + if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { + return path.join(os.homedir(), inputPath.slice(2)); + } + + return inputPath; +} + +function resolveDestinationPath(inputPath: string): string { + return path.resolve(expandHomePrefix(inputPath)); +} + +function isInteractiveTTY(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true; +} + +async function promptConfirm(question: string): Promise { + if (!isInteractiveTTY()) { return false; } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, + const result = await clack.confirm({ + message: question, + initialValue: false, }); - return new Promise((resolve) => { - rl.question(`${question} [y/N]: `, (answer) => { - rl.close(); - resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes'); - }); - }); + if (clack.isCancel(result)) { + clack.cancel('Operation cancelled.'); + return false; + } + + return result; } interface InstallResult { @@ -105,15 +121,15 @@ async function installSkill( fs.rmSync(altDir, { recursive: true, force: true }); } else { const altType = skillType === 'mcp' ? 'cli' : 'mcp'; - if (!process.stdin.isTTY) { + if (!isInteractiveTTY()) { throw new Error( - `Conflicting skill "${altSkillDirName(skillType)}" found in ${skillsDir}. ` + + `Installing ${skillDisplayName(skillType)} but conflicting ${altType} skill found in ${skillsDir}. ` + `Use --remove-conflict to auto-remove it, or uninstall the ${altType} skill first.`, ); } - const confirmed = await promptYesNo( - `Conflicting skill "${altSkillDirName(skillType)}" found in ${skillsDir}.\n Remove it?`, + const confirmed = await promptConfirm( + `Installing ${skillDisplayName(skillType)} but a conflicting ${altType} skill exists in ${skillsDir}. Remove it?`, ); if (!confirmed) { throw new Error('Installation cancelled due to conflicting skill.'); @@ -123,11 +139,11 @@ async function installSkill( } if (fs.existsSync(targetFile) && !opts.force) { - if (!process.stdin.isTTY) { + if (!isInteractiveTTY()) { throw new Error(`Skill already installed at ${targetFile}. Use --force to overwrite.`); } - const confirmed = await promptYesNo(`Skill already installed at ${targetFile}.\n Overwrite?`); + const confirmed = await promptConfirm(`Skill already installed at ${targetFile}. Overwrite?`); if (!confirmed) { throw new Error('Installation cancelled.'); } @@ -165,7 +181,7 @@ function resolveTargets( operation: 'install' | 'uninstall', ): ClientInfo[] { if (destFlag) { - const resolvedDest = path.resolve(destFlag); + const resolvedDest = resolveDestinationPath(destFlag); if (resolvedDest === path.parse(resolvedDest).root) { throw new Error( 'Refusing to use filesystem root as skills destination. Use a dedicated directory.', @@ -177,7 +193,7 @@ function resolveTargets( if (clientFlag && clientFlag !== 'auto') { const def = CLIENT_DEFINITIONS.find((d) => d.id === clientFlag); if (!def) { - throw new Error(`Unknown client: ${clientFlag}. Valid clients: claude, cursor, codex`); + throw new Error(`Unknown client: ${clientFlag}. Valid clients: claude, agents`); } const home = os.homedir(); return [{ name: def.name, id: def.id, skillsDir: path.join(home, def.skillsSubdir) }]; @@ -197,6 +213,143 @@ function resolveTargets( return detected; } +const CUSTOM_PATH_SENTINEL = '__custom__'; + +interface InitSelection { + skillType: SkillType; + targets: ClientInfo[]; +} + +async function collectInitSelection( + argv: { skill?: string; client?: string; dest?: string }, + prompter: Prompter, +): Promise { + const destProvided = argv.dest !== undefined; + + const interactive = isInteractiveTTY(); + + let skillType: SkillType; + if (argv.skill !== undefined) { + skillType = argv.skill as SkillType; + } else if (interactive) { + skillType = await prompter.selectOne({ + message: 'Which skill variant to install?', + options: [ + { + value: 'cli', + label: 'XcodeBuildMCP CLI', + description: 'Recommended for most users', + }, + { + value: 'mcp', + label: 'XcodeBuildMCP MCP Server', + description: 'For MCP server usage', + }, + ], + initialIndex: 0, + }); + } else { + skillType = 'cli'; + } + + if (destProvided) { + const resolvedDest = resolveDestinationPath(argv.dest!); + if (resolvedDest === path.parse(resolvedDest).root) { + throw new Error( + 'Refusing to use filesystem root as skills destination. Use a dedicated directory.', + ); + } + return { + skillType, + targets: [{ name: 'Custom', id: 'custom', skillsDir: resolvedDest }], + }; + } + + if (argv.client !== undefined) { + const targets = resolveTargets(argv.client, undefined, 'install'); + return { skillType, targets }; + } + + if (!interactive) { + throw new Error( + 'Non-interactive mode requires --client or --dest for init. Use --print to output the skill content without installing.', + ); + } + + const home = os.homedir(); + const detected = detectClients(); + const detectedIds = new Set(detected.map((c) => c.id)); + + const options: { value: string; label: string; description?: string }[] = []; + for (const def of CLIENT_DEFINITIONS) { + const isDetected = detectedIds.has(def.id); + const dir = path.join(home, def.skillsSubdir); + options.push({ + value: def.id, + label: `${def.name}${isDetected ? ' (detected)' : ''}`, + description: dir, + }); + } + options.push({ + value: CUSTOM_PATH_SENTINEL, + label: 'Custom path...', + description: 'Enter a custom directory path', + }); + + const selected = await prompter.selectMany({ + message: 'Where should the skill be installed?', + options, + initialSelectedKeys: detectedIds, + getKey: (value) => value, + minSelected: 1, + }); + + const targets: ClientInfo[] = []; + for (const id of selected) { + if (id === CUSTOM_PATH_SENTINEL) { + const customPath = await promptCustomPath(); + targets.push({ name: 'Custom', id: 'custom', skillsDir: customPath }); + } else { + const def = CLIENT_DEFINITIONS.find((d) => d.id === id); + if (!def) { + throw new Error(`Unknown client target: ${id}`); + } + targets.push({ + name: def.name, + id: def.id, + skillsDir: path.join(home, def.skillsSubdir), + }); + } + } + + return { skillType, targets }; +} + +async function promptCustomPath(): Promise { + if (!isInteractiveTTY()) { + throw new Error('Cannot prompt for custom path in non-interactive mode. Use --dest instead.'); + } + + const result = await clack.text({ + message: 'Enter the destination directory path:', + validate: (value: string | undefined) => { + if (!value?.trim()) return 'Path cannot be empty.'; + const resolved = resolveDestinationPath(value); + if (resolved === path.parse(resolved).root) { + return 'Refusing to use filesystem root. Use a dedicated directory.'; + } + return undefined; + }, + }); + + if (clack.isCancel(result)) { + clack.cancel('Operation cancelled.'); + throw new Error('Operation cancelled.'); + } + + return resolveDestinationPath(result as string); +} + export function registerInitCommand(app: Argv): void { app.command( 'init', @@ -205,15 +358,13 @@ export function registerInitCommand(app: Argv): void { return yargs .option('client', { type: 'string', - describe: 'Target client: claude, cursor, codex (default: auto-detect)', - choices: ['auto', 'claude', 'cursor', 'codex'] as const, - default: 'auto', + describe: 'Target client: claude, agents (default: auto-detect)', + choices: ['auto', 'claude', 'agents'] as const, }) .option('skill', { type: 'string', - describe: 'Skill variant: mcp or cli', + describe: 'Skill variant: mcp or cli (default: cli)', choices: ['mcp', 'cli'] as const, - default: 'cli', }) .option('dest', { type: 'string', @@ -241,17 +392,22 @@ export function registerInitCommand(app: Argv): void { }); }, async (argv) => { - const skillType = argv.skill as SkillType; - if (argv.print) { - const content = readSkillContent(skillType); + const content = readSkillContent((argv.skill as SkillType | undefined) ?? 'cli'); process.stdout.write(content); return; } + const isTTY = isInteractiveTTY(); + if (argv.uninstall) { + if (isTTY) { + clack.intro('XcodeBuildMCP Init'); + clack.log.info('Removing XcodeBuildMCP agent skills from detected AI clients.'); + } + const targets = resolveTargets( - argv.client as string | undefined, + (argv.client as string | undefined) ?? 'auto', argv.dest as string | undefined, 'uninstall', ); @@ -261,41 +417,61 @@ export function registerInitCommand(app: Argv): void { const result = uninstallSkill(target.skillsDir, target.name); if (result) { if (!anyRemoved) { - writeLine('Uninstalled skill directories'); - } - writeLine(` Client: ${result.client}`); - for (const removed of result.removed) { - writeLine(` Removed (${removed.variant}): ${removed.path}`); + clack.log.step('Uninstalled skill directories'); } + const removedLines = result.removed + .map((r) => ` Removed (${r.variant}): ${r.path}`) + .join('\n'); + clack.log.message(` Client: ${result.client}\n${removedLines}`); anyRemoved = true; } } if (!anyRemoved) { - writeLine('No installed skill directories found to remove.'); + clack.log.info('No installed skill directories found to remove.'); + } + + if (isTTY) { + clack.outro(anyRemoved ? 'Done.' : undefined); } return; } - const targets = resolveTargets( - argv.client as string | undefined, - argv.dest as string | undefined, - 'install', + if (isTTY) { + clack.intro('XcodeBuildMCP Init'); + clack.log.info( + 'Install the XcodeBuildMCP agent skill to your AI coding clients.\n' + + 'The skill teaches your AI assistant how to use XcodeBuildMCP\n' + + 'effectively for building, testing, and debugging your apps.', + ); + } + + const prompter = createPrompter(); + const selection = await collectInitSelection( + { + skill: argv.skill as string | undefined, + client: argv.client as string | undefined, + dest: argv.dest as string | undefined, + }, + prompter, ); const results: InstallResult[] = []; - for (const target of targets) { - const result = await installSkill(target.skillsDir, target.name, skillType, { + for (const target of selection.targets) { + const result = await installSkill(target.skillsDir, target.name, selection.skillType, { force: argv.force as boolean, removeConflict: argv['remove-conflict'] as boolean, }); results.push(result); } - writeLine(`Installed ${skillDisplayName(skillType)} skill`); + clack.log.success(`Installed ${skillDisplayName(selection.skillType)} skill`); for (const result of results) { - writeLine(` Client: ${result.client}`); - writeLine(` Location: ${result.location}`); + clack.log.message(` Client: ${result.client}\n Location: ${result.location}`); + } + + if (isTTY) { + clack.outro('Done.'); } }, ); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts new file mode 100644 index 00000000..c1122d44 --- /dev/null +++ b/src/cli/commands/setup.ts @@ -0,0 +1,567 @@ +import type { Argv } from 'yargs'; +import path from 'node:path'; +import * as clack from '@clack/prompts'; +import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../utils/command.ts'; +import { discoverProjects } from '../../mcp/tools/project-discovery/discover_projs.ts'; +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 { isWorkflowEnabledForRuntime } from '../../visibility/exposure.ts'; +import { getConfig } from '../../utils/config-store.ts'; +import { + loadProjectConfig, + persistProjectConfigPatch, + type ProjectConfig, +} from '../../utils/project-config.ts'; +import { createPrompter, type Prompter, type SelectOption } from '../interactive/prompts.ts'; +import type { FileSystemExecutor } from '../../utils/FileSystemExecutor.ts'; +import type { CommandExecutor } from '../../utils/CommandExecutor.ts'; +import { createDoctorDependencies } from '../../mcp/tools/doctor/lib/doctor.deps.ts'; + +interface SetupSelection { + debug: boolean; + sentryDisabled: boolean; + enabledWorkflows: string[]; + projectPath?: string; + workspacePath?: string; + scheme: string; + simulatorId: string; + simulatorName: string; +} + +interface SetupDependencies { + cwd: string; + fs: FileSystemExecutor; + executor: CommandExecutor; + prompter: Prompter; + quietOutput: boolean; +} + +export interface SetupRunResult { + configPath: string; + changedFields: string[]; +} + +const WORKFLOW_EXCLUDES = new Set(['session-management', 'workflow-discovery']); + +function showPromptHelp(helpText: string, quietOutput: boolean): void { + if (quietOutput) { + return; + } + + clack.log.message(helpText); +} + +function isInteractiveTTY(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true; +} + +async function withSpinner(opts: { + isTTY: boolean; + quietOutput: boolean; + startMessage: string; + stopMessage: string; + task: () => Promise; +}): Promise { + if (!opts.isTTY || opts.quietOutput) { + return opts.task(); + } + + const s = clack.spinner(); + s.start(opts.startMessage); + const result = await opts.task(); + s.stop(opts.stopMessage); + return result; +} + +function valuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function formatSummaryValue(value: unknown): string { + if (value === undefined) { + return '(not set)'; + } + + return JSON.stringify(value); +} + +function relativePathOrAbsolute(absolutePath: string, cwd: string): string { + const relative = path.relative(cwd, absolutePath); + if (relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return relative; + } + + return absolutePath; +} + +function normalizeExistingDefaults(config?: ProjectConfig): { + projectPath?: string; + workspacePath?: string; + scheme?: string; + simulatorId?: string; + simulatorName?: string; +} { + const sessionDefaults = config?.sessionDefaults ?? {}; + return { + projectPath: sessionDefaults.projectPath, + workspacePath: sessionDefaults.workspacePath, + scheme: sessionDefaults.scheme, + simulatorId: sessionDefaults.simulatorId, + simulatorName: sessionDefaults.simulatorName, + }; +} + +function getWorkflowOptions(debug: boolean): WorkflowManifestEntry[] { + const manifest = loadManifest(); + const config = getConfig(); + + const predicateContext = { + runtime: 'mcp' as const, + config: { + ...config, + debug, + }, + runningUnderXcode: false, + }; + + return Array.from(manifest.workflows.values()) + .filter((workflow) => !WORKFLOW_EXCLUDES.has(workflow.id)) + .filter((workflow) => isWorkflowEnabledForRuntime(workflow, predicateContext)) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +function getChangedFields( + beforeConfig: ProjectConfig | undefined, + afterConfig: ProjectConfig, +): string[] { + const beforeDefaults = beforeConfig?.sessionDefaults ?? {}; + const afterDefaults = afterConfig.sessionDefaults ?? {}; + + const fieldComparisons: Array<{ label: string; beforeValue: unknown; afterValue: unknown }> = [ + { label: 'debug', beforeValue: beforeConfig?.debug, afterValue: afterConfig.debug }, + { + label: 'sentryDisabled', + beforeValue: beforeConfig?.sentryDisabled, + afterValue: afterConfig.sentryDisabled, + }, + { + label: 'enabledWorkflows', + beforeValue: beforeConfig?.enabledWorkflows, + afterValue: afterConfig.enabledWorkflows, + }, + { + label: 'sessionDefaults.projectPath', + beforeValue: beforeDefaults.projectPath, + afterValue: afterDefaults.projectPath, + }, + { + label: 'sessionDefaults.workspacePath', + beforeValue: beforeDefaults.workspacePath, + afterValue: afterDefaults.workspacePath, + }, + { + label: 'sessionDefaults.scheme', + beforeValue: beforeDefaults.scheme, + afterValue: afterDefaults.scheme, + }, + { + label: 'sessionDefaults.simulatorId', + beforeValue: beforeDefaults.simulatorId, + afterValue: afterDefaults.simulatorId, + }, + { + label: 'sessionDefaults.simulatorName', + beforeValue: beforeDefaults.simulatorName, + afterValue: afterDefaults.simulatorName, + }, + ]; + + const changed: string[] = []; + for (const comparison of fieldComparisons) { + if (!valuesEqual(comparison.beforeValue, comparison.afterValue)) { + changed.push( + `${comparison.label}: ${formatSummaryValue(comparison.beforeValue)} → ${formatSummaryValue(comparison.afterValue)}`, + ); + } + } + + return changed; +} + +async function selectWorkflowIds(opts: { + debug: boolean; + existingEnabledWorkflows: string[]; + prompter: Prompter; + quietOutput: boolean; +}): Promise { + const workflows = getWorkflowOptions(opts.debug); + if (workflows.length === 0) { + return []; + } + + const workflowOptions: SelectOption[] = workflows.map((workflow) => ({ + value: workflow.id, + label: workflow.id, + description: workflow.description, + })); + + const defaults = + opts.existingEnabledWorkflows.length > 0 ? opts.existingEnabledWorkflows : ['simulator']; + + showPromptHelp( + 'Select workflows to choose which groups of tools are enabled by default in this project.', + opts.quietOutput, + ); + const selected = await opts.prompter.selectMany({ + message: 'Select workflows to enable', + options: workflowOptions, + initialSelectedKeys: new Set(defaults), + getKey: (value) => value, + minSelected: 1, + }); + + return selected; +} + +type ProjectChoice = { kind: 'workspace' | 'project'; absolutePath: string }; + +async function selectProjectChoice(opts: { + cwd: string; + existingProjectPath?: string; + existingWorkspacePath?: string; + fs: FileSystemExecutor; + prompter: Prompter; + isTTY: boolean; + quietOutput: boolean; +}): Promise { + const discovered = await withSpinner({ + isTTY: opts.isTTY, + quietOutput: opts.quietOutput, + startMessage: 'Discovering projects...', + stopMessage: 'Projects discovered.', + task: () => discoverProjects({ workspaceRoot: opts.cwd }, opts.fs), + }); + const choices: ProjectChoice[] = [ + ...discovered.workspaces.map((absolutePath) => ({ kind: 'workspace' as const, absolutePath })), + ...discovered.projects.map((absolutePath) => ({ kind: 'project' as const, absolutePath })), + ]; + + if (choices.length === 0) { + throw new Error('No Xcode project or workspace files were discovered.'); + } + + const defaultPath = opts.existingWorkspacePath ?? opts.existingProjectPath; + const defaultIndex = choices.findIndex((choice) => choice.absolutePath === defaultPath); + + const projectOptions: SelectOption[] = choices.map((choice) => ({ + value: choice, + label: `${choice.kind === 'workspace' ? 'Workspace' : 'Project'}: ${relativePathOrAbsolute(choice.absolutePath, opts.cwd)}`, + })); + + showPromptHelp( + 'Select a project or workspace to set the default path used by build and run commands.', + opts.quietOutput, + ); + return opts.prompter.selectOne({ + message: 'Select a project or workspace', + options: projectOptions, + initialIndex: defaultIndex >= 0 ? defaultIndex : 0, + }); +} + +async function selectScheme(opts: { + projectChoice: ProjectChoice; + existingScheme?: string; + executor: CommandExecutor; + prompter: Prompter; + isTTY: boolean; + quietOutput: boolean; +}): Promise { + const schemeArgs = + opts.projectChoice.kind === 'workspace' + ? { workspacePath: opts.projectChoice.absolutePath } + : { projectPath: opts.projectChoice.absolutePath }; + + const schemes = await withSpinner({ + isTTY: opts.isTTY, + quietOutput: opts.quietOutput, + startMessage: 'Loading schemes...', + stopMessage: 'Schemes loaded.', + task: () => listSchemes(schemeArgs, opts.executor), + }); + + if (schemes.length === 0) { + throw new Error('No schemes were found for the selected project/workspace.'); + } + + const defaultIndex = + opts.existingScheme != null ? schemes.findIndex((scheme) => scheme === opts.existingScheme) : 0; + + showPromptHelp( + 'Select a scheme to set the default used when you do not pass --scheme.', + opts.quietOutput, + ); + return opts.prompter.selectOne({ + message: 'Select a scheme', + options: schemes.map((scheme) => ({ value: scheme, label: scheme })), + initialIndex: defaultIndex >= 0 ? defaultIndex : 0, + }); +} + +function getDefaultSimulatorIndex( + simulators: ListedSimulator[], + existingSimulatorId?: string, + existingSimulatorName?: string, +): number { + if (existingSimulatorId != null) { + const byId = simulators.findIndex((simulator) => simulator.udid === existingSimulatorId); + if (byId >= 0) { + return byId; + } + } + + if (existingSimulatorName != null) { + const byName = simulators.findIndex((simulator) => simulator.name === existingSimulatorName); + if (byName >= 0) { + return byName; + } + } + + const booted = simulators.findIndex((simulator) => simulator.state === 'Booted'); + return booted >= 0 ? booted : 0; +} + +async function selectSimulator(opts: { + existingSimulatorId?: string; + existingSimulatorName?: string; + executor: CommandExecutor; + prompter: Prompter; + isTTY: boolean; + quietOutput: boolean; +}): Promise { + const simulators = await withSpinner({ + isTTY: opts.isTTY, + quietOutput: opts.quietOutput, + startMessage: 'Loading simulators...', + stopMessage: 'Simulators loaded.', + task: () => listSimulators(opts.executor), + }); + if (simulators.length === 0) { + throw new Error('No available simulators were found.'); + } + + const defaultIndex = getDefaultSimulatorIndex( + simulators, + opts.existingSimulatorId, + opts.existingSimulatorName, + ); + + showPromptHelp( + 'Select a simulator to set the default device target used by simulator commands.', + opts.quietOutput, + ); + return opts.prompter.selectOne({ + message: 'Select a simulator', + options: simulators.map((simulator) => ({ + value: simulator, + label: `${simulator.runtime} — ${simulator.name} (${simulator.udid})`, + description: simulator.state, + })), + initialIndex: defaultIndex, + }); +} + +async function ensureSetupPrerequisites(opts: { + executor: CommandExecutor; + isTTY: boolean; + quietOutput: boolean; +}): Promise { + const doctorDependencies = createDoctorDependencies(opts.executor); + const xcodeInfo = await withSpinner({ + isTTY: opts.isTTY, + quietOutput: opts.quietOutput, + startMessage: 'Checking Xcode command line tools...', + stopMessage: 'Xcode command line tools check complete.', + task: () => doctorDependencies.xcode.getXcodeInfo(), + }); + + if (!('error' in xcodeInfo)) { + return; + } + + throw new Error( + `Setup prerequisites failed: ${xcodeInfo.error}. Run \`xcodebuildmcp doctor\` for details.`, + ); +} + +async function collectSetupSelection( + existingConfig: ProjectConfig | undefined, + deps: SetupDependencies, +): Promise { + const existing = normalizeExistingDefaults(existingConfig); + + showPromptHelp( + 'Enable debug mode to turn on more verbose logging and diagnostics while using XcodeBuildMCP.', + deps.quietOutput, + ); + const debug = await deps.prompter.confirm({ + message: 'Enable debug mode?', + defaultValue: existingConfig?.debug ?? false, + }); + + showPromptHelp( + 'Disable Sentry telemetry to stop sending anonymous runtime diagnostics for XcodeBuildMCP itself (not your app, project code, or build errors).', + deps.quietOutput, + ); + const sentryDisabled = await deps.prompter.confirm({ + message: 'Disable Sentry telemetry?', + defaultValue: existingConfig?.sentryDisabled ?? false, + }); + + const enabledWorkflows = await selectWorkflowIds({ + debug, + existingEnabledWorkflows: existingConfig?.enabledWorkflows ?? [], + prompter: deps.prompter, + quietOutput: deps.quietOutput, + }); + + const isTTY = isInteractiveTTY(); + + const projectChoice = await selectProjectChoice({ + cwd: deps.cwd, + existingProjectPath: existing.projectPath, + existingWorkspacePath: existing.workspacePath, + fs: deps.fs, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }); + + const scheme = await selectScheme({ + projectChoice, + existingScheme: existing.scheme, + executor: deps.executor, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }); + + const simulator = await selectSimulator({ + existingSimulatorId: existing.simulatorId, + existingSimulatorName: existing.simulatorName, + executor: deps.executor, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }); + + return { + debug, + sentryDisabled, + enabledWorkflows, + projectPath: projectChoice.kind === 'project' ? projectChoice.absolutePath : undefined, + workspacePath: projectChoice.kind === 'workspace' ? projectChoice.absolutePath : undefined, + scheme, + simulatorId: simulator.udid, + simulatorName: simulator.name, + }; +} + +export async function runSetupWizard(deps?: Partial): Promise { + const isTTY = isInteractiveTTY(); + if (!isTTY) { + throw new Error('`xcodebuildmcp setup` requires an interactive TTY.'); + } + + const resolvedDeps: SetupDependencies = { + cwd: deps?.cwd ?? process.cwd(), + fs: deps?.fs ?? getDefaultFileSystemExecutor(), + executor: deps?.executor ?? getDefaultCommandExecutor(), + prompter: deps?.prompter ?? createPrompter(), + quietOutput: deps?.quietOutput ?? false, + }; + + if (!resolvedDeps.quietOutput) { + clack.intro('XcodeBuildMCP Setup'); + clack.log.info( + 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + + 'You will select a project or workspace, scheme, simulator, and\n' + + 'which workflows to enable. Settings are saved to\n' + + '.xcodebuildmcp/config.yaml in your project directory.', + ); + } + + await ensureSetupPrerequisites({ + executor: resolvedDeps.executor, + isTTY, + quietOutput: resolvedDeps.quietOutput, + }); + + const beforeResult = await loadProjectConfig({ fs: resolvedDeps.fs, cwd: resolvedDeps.cwd }); + const beforeConfig = beforeResult.found ? beforeResult.config : undefined; + + const selection = await collectSetupSelection(beforeConfig, resolvedDeps); + + const deleteSessionDefaultKeys: Array<'projectPath' | 'workspacePath'> = + selection.workspacePath != null ? ['projectPath'] : ['workspacePath']; + + const persistedProjectPath = + selection.projectPath != null + ? relativePathOrAbsolute(selection.projectPath, resolvedDeps.cwd) + : undefined; + const persistedWorkspacePath = + selection.workspacePath != null + ? relativePathOrAbsolute(selection.workspacePath, resolvedDeps.cwd) + : undefined; + + const persistedResult = await persistProjectConfigPatch({ + fs: resolvedDeps.fs, + cwd: resolvedDeps.cwd, + patch: { + enabledWorkflows: selection.enabledWorkflows, + debug: selection.debug, + sentryDisabled: selection.sentryDisabled, + sessionDefaults: { + projectPath: persistedProjectPath, + workspacePath: persistedWorkspacePath, + scheme: selection.scheme, + simulatorId: selection.simulatorId, + simulatorName: selection.simulatorName, + }, + }, + deleteSessionDefaultKeys, + }); + + const afterResult = await loadProjectConfig({ fs: resolvedDeps.fs, cwd: resolvedDeps.cwd }); + if (!afterResult.found) { + throw new Error('Failed to reload config after setup.'); + } + + const changedFields = getChangedFields(beforeConfig, afterResult.config); + + if (!resolvedDeps.quietOutput) { + if (changedFields.length === 0) { + clack.note('No changes.', persistedResult.path); + } else { + clack.note(changedFields.map((field) => `- ${field}`).join('\n'), persistedResult.path); + } + clack.outro('Setup complete.'); + } + + return { + configPath: persistedResult.path, + changedFields, + }; +} + +export function registerSetupCommand(app: Argv): void { + app.command( + 'setup', + 'Interactively create or update .xcodebuildmcp/config.yaml', + (yargs) => yargs, + async () => { + await runSetupWizard(); + }, + ); +} diff --git a/src/cli/interactive/prompts.ts b/src/cli/interactive/prompts.ts new file mode 100644 index 00000000..d0736a44 --- /dev/null +++ b/src/cli/interactive/prompts.ts @@ -0,0 +1,145 @@ +import * as clack from '@clack/prompts'; + +export interface SelectOption { + value: T; + label: string; + description?: string; +} + +export interface Prompter { + selectOne(opts: { + message: string; + options: SelectOption[]; + initialIndex?: number; + }): Promise; + selectMany(opts: { + message: string; + options: SelectOption[]; + initialSelectedKeys?: ReadonlySet; + getKey: (value: T) => string; + minSelected?: number; + }): Promise; + confirm(opts: { message: string; defaultValue: boolean }): Promise; +} + +function clampIndex(index: number, optionsLength: number): number { + if (optionsLength <= 0) return 0; + return Math.max(0, Math.min(index, optionsLength - 1)); +} + +function createNonInteractivePrompter(): Prompter { + return { + async selectOne(opts: { options: SelectOption[]; initialIndex?: number }): Promise { + const index = clampIndex(opts.initialIndex ?? 0, opts.options.length); + return opts.options[index].value; + }, + async selectMany(opts: { + options: SelectOption[]; + initialSelectedKeys?: ReadonlySet; + getKey: (value: T) => string; + minSelected?: number; + }): Promise { + const selected = opts.options.filter((option) => + (opts.initialSelectedKeys ?? new Set()).has(opts.getKey(option.value)), + ); + if (selected.length > 0) { + return selected.map((option) => option.value); + } + + const minSelected = opts.minSelected ?? 0; + return opts.options.slice(0, minSelected).map((option) => option.value); + }, + async confirm(opts: { defaultValue: boolean }): Promise { + return opts.defaultValue; + }, + }; +} + +function handleCancel(result: unknown): void { + if (clack.isCancel(result)) { + clack.cancel('Setup cancelled.'); + throw new Error('Setup cancelled.'); + } +} + +function createTtyPrompter(): Prompter { + return { + async selectOne(opts: { + message: string; + options: SelectOption[]; + initialIndex?: number; + }): Promise { + if (opts.options.length === 0) { + throw new Error('No options available for selection.'); + } + + const initialIndex = clampIndex(opts.initialIndex ?? 0, opts.options.length); + + const promptOptions = opts.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.description ? { hint: option.description } : {}), + })) as unknown as clack.Option[]; + + const result = await clack.select({ + message: opts.message, + options: promptOptions, + initialValue: opts.options[initialIndex].value, + }); + + handleCancel(result); + return result as T; + }, + + async selectMany(opts: { + message: string; + options: SelectOption[]; + initialSelectedKeys?: ReadonlySet; + getKey: (value: T) => string; + minSelected?: number; + }): Promise { + if (opts.options.length === 0) { + return []; + } + + const initialKeys = opts.initialSelectedKeys ?? new Set(); + const initialValues = opts.options + .filter((option) => initialKeys.has(opts.getKey(option.value))) + .map((option) => option.value); + + const promptOptions = opts.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.description ? { hint: option.description } : {}), + })) as unknown as clack.Option[]; + + const result = await clack.multiselect({ + message: opts.message, + options: promptOptions, + initialValues, + required: (opts.minSelected ?? 0) > 0, + }); + + handleCancel(result); + return result as T[]; + }, + + async confirm(opts: { message: string; defaultValue: boolean }): Promise { + const result = await clack.confirm({ + message: opts.message, + initialValue: opts.defaultValue, + }); + + handleCancel(result); + return result as boolean; + }, + }; +} + +export function createPrompter(): Prompter { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return createNonInteractivePrompter(); + } + + return createTtyPrompter(); +} diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 5420b3ad..aefe37d1 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -5,6 +5,7 @@ import type { ResolvedRuntimeConfig } from '../utils/config-store.ts'; import { registerDaemonCommands } from './commands/daemon.ts'; import { registerInitCommand } from './commands/init.ts'; import { registerMcpCommand } from './commands/mcp.ts'; +import { registerSetupCommand } from './commands/setup.ts'; import { registerToolsCommand } from './commands/tools.ts'; import { registerToolCommands } from './register-tool-commands.ts'; import { version } from '../version.ts'; @@ -72,6 +73,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { // Register command groups with workspace context registerMcpCommand(app); registerInitCommand(app); + registerSetupCommand(app); registerToolsCommand(app); registerToolCommands(app, opts.catalog, { workspaceRoot: opts.workspaceRoot, diff --git a/src/daemon.ts b/src/daemon.ts index 4c691559..75d2ed8a 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -41,6 +41,7 @@ import { setSentryRuntimeContext, } from './utils/sentry.ts'; import { isXcodemakeBinaryAvailable, isXcodemakeEnabled } from './utils/xcodemake/index.ts'; +import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts'; async function checkExistingDaemon(socketPath: string): Promise { return new Promise((resolve) => { @@ -154,6 +155,9 @@ async function main(): Promise { setLogLevel(resolveLogLevel() ?? 'info'); } + await hydrateSentryDisabledEnvFromProjectConfig({ + cwd: result.runtime.cwd, + }); initSentry({ mode: 'cli-daemon' }); recordDaemonLifecycleMetric('start'); diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts index f45e9da9..1a5180ed 100644 --- a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; -import { schema, handler, discover_projsLogic } from '../discover_projs.ts'; +import { schema, handler, discover_projsLogic, discoverProjects } from '../discover_projs.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; describe('discover_projs plugin', () => { @@ -58,6 +58,21 @@ describe('discover_projs plugin', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { + it('returns structured discovery results for setup flows', async () => { + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); + mockFileSystemExecutor.readdir = async () => [ + { name: 'App.xcodeproj', isDirectory: () => true, isSymbolicLink: () => false }, + { name: 'App.xcworkspace', isDirectory: () => true, isSymbolicLink: () => false }, + ]; + + const result = await discoverProjects( + { workspaceRoot: '/workspace' }, + mockFileSystemExecutor, + ); + expect(result.projects).toEqual(['/workspace/App.xcodeproj']); + expect(result.workspaces).toEqual(['/workspace/App.xcworkspace']); + }); + it('should handle workspaceRoot parameter correctly when provided', async () => { mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true, mtimeMs: 0 }); mockFileSystemExecutor.readdir = async () => []; diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts index 4d9f1ff3..f5184009 100644 --- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -10,7 +10,7 @@ import { createMockCommandResponse, createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -import { schema, handler, listSchemesLogic } from '../list_schemes.ts'; +import { schema, handler, listSchemes, listSchemesLogic } from '../list_schemes.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('list_schemes plugin', () => { @@ -191,6 +191,22 @@ describe('list_schemes plugin', () => { }); }); + it('returns parsed schemes for setup flows', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `Information about project "MyProject": + Schemes: + MyProject + MyProjectTests`, + }); + + const schemes = await listSchemes( + { projectPath: '/path/to/MyProject.xcodeproj' }, + mockExecutor, + ); + expect(schemes).toEqual(['MyProject', 'MyProjectTests']); + }); + it('should verify command generation with mock executor', async () => { const calls: any[] = []; const mockExecutor = async ( diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index f0f3c9f3..e11108e5 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -25,6 +25,29 @@ interface DirentLike { isSymbolicLink(): boolean; } +function getErrorDetails( + error: unknown, + fallbackMessage: string, +): { code?: string; message: string } { + if (error instanceof Error) { + const errorWithCode = error as Error & { code?: unknown }; + return { + code: typeof errorWithCode.code === 'string' ? errorWithCode.code : undefined, + message: error.message, + }; + } + + if (typeof error === 'object' && error !== null) { + const candidate = error as { code?: unknown; message?: unknown }; + return { + code: typeof candidate.code === 'string' ? candidate.code : undefined, + message: typeof candidate.message === 'string' ? candidate.message : fallbackMessage, + }; + } + + return { message: String(error) }; +} + /** * Recursively scans directories to find Xcode projects and workspaces. */ @@ -103,24 +126,7 @@ async function _findProjectsRecursive( } } } catch (error) { - let code; - let message = 'Unknown error'; - - if (error instanceof Error) { - message = error.message; - if ('code' in error) { - code = error.code; - } - } else if (typeof error === 'object' && error !== null) { - if ('message' in error && typeof error.message === 'string') { - message = error.message; - } - if ('code' in error && typeof error.code === 'string') { - code = error.code; - } - } else { - message = String(error); - } + const { code, message } = getErrorDetails(error, 'Unknown error'); if (code === 'EPERM' || code === 'EACCES') { log('debug', `Permission denied scanning directory: ${currentDirAbs}`); @@ -140,86 +146,59 @@ const discoverProjsSchema = z.object({ maxDepth: z.number().int().nonnegative().optional(), }); +export interface DiscoverProjectsParams { + workspaceRoot: string; + scanPath?: string; + maxDepth?: number; +} + +export interface DiscoverProjectsResult { + projects: string[]; + workspaces: string[]; +} + // Use z.infer for type safety type DiscoverProjsParams = z.infer; -/** - * Business logic for discovering projects. - * Exported for testing purposes. - */ -export async function discover_projsLogic( - params: DiscoverProjsParams, +async function discoverProjectsOrError( + params: DiscoverProjectsParams, fileSystemExecutor: FileSystemExecutor, -): Promise { - // Apply defaults +): Promise { const scanPath = params.scanPath ?? '.'; const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH; const workspaceRoot = params.workspaceRoot; - const relativeScanPath = scanPath; - - // Calculate and validate the absolute scan path - const requestedScanPath = path.resolve(workspaceRoot, relativeScanPath ?? '.'); + const requestedScanPath = path.resolve(workspaceRoot, scanPath); let absoluteScanPath = requestedScanPath; const normalizedWorkspaceRoot = path.normalize(workspaceRoot); if (!path.normalize(absoluteScanPath).startsWith(normalizedWorkspaceRoot)) { log( 'warn', - `Requested scan path '${relativeScanPath}' resolved outside workspace root '${workspaceRoot}'. Defaulting scan to workspace root.`, + `Requested scan path '${scanPath}' resolved outside workspace root '${workspaceRoot}'. Defaulting scan to workspace root.`, ); absoluteScanPath = normalizedWorkspaceRoot; } - const results = { projects: [], workspaces: [] }; - log( 'info', `Starting project discovery request: path=${absoluteScanPath}, maxDepth=${maxDepth}, workspace=${workspaceRoot}`, ); try { - // Ensure the scan path exists and is a directory const stats = await fileSystemExecutor.stat(absoluteScanPath); if (!stats.isDirectory()) { const errorMsg = `Scan path is not a directory: ${absoluteScanPath}`; log('error', errorMsg); - // Return ToolResponse error format - return { - content: [createTextContent(errorMsg)], - isError: true, - }; + return { error: errorMsg }; } } catch (error) { - let code; - let message = 'Unknown error accessing scan path'; - - // Type guards - refined - if (error instanceof Error) { - message = error.message; - // Check for code property specific to Node.js fs errors - if ('code' in error) { - code = error.code; - } - } else if (typeof error === 'object' && error !== null) { - if ('message' in error && typeof error.message === 'string') { - message = error.message; - } - if ('code' in error && typeof error.code === 'string') { - code = error.code; - } - } else { - message = String(error); - } - + const { code, message } = getErrorDetails(error, 'Unknown error accessing scan path'); const errorMsg = `Failed to access scan path: ${absoluteScanPath}. Error: ${message}`; log('error', `${errorMsg} - Code: ${code ?? 'N/A'}`); - return { - content: [createTextContent(errorMsg)], - isError: true, - }; + return { error: errorMsg }; } - // Start the recursive scan from the validated absolute path + const results: DiscoverProjectsResult = { projects: [], workspaces: [] }; await _findProjectsRecursive( absoluteScanPath, workspaceRoot, @@ -229,6 +208,38 @@ export async function discover_projsLogic( fileSystemExecutor, ); + results.projects.sort(); + results.workspaces.sort(); + return results; +} + +export async function discoverProjects( + params: DiscoverProjectsParams, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const result = await discoverProjectsOrError(params, fileSystemExecutor); + if ('error' in result) { + throw new Error(result.error); + } + return result; +} + +/** + * Business logic for discovering projects. + * Exported for testing purposes. + */ +export async function discover_projsLogic( + params: DiscoverProjsParams, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const results = await discoverProjectsOrError(params, fileSystemExecutor); + if ('error' in results) { + return { + content: [createTextContent(results.error)], + isError: true, + }; + } + log( 'info', `Discovery finished. Found ${results.projects.length} projects and ${results.workspaces.length} workspaces.`, @@ -240,10 +251,6 @@ export async function discover_projsLogic( ), ]; - // Sort results for consistent output - results.projects.sort(); - results.workspaces.sort(); - if (results.projects.length > 0) { responseContent.push( createTextContent(`Projects found:\n - ${results.projects.join('\n - ')}`), diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index fabd68c8..b7f98d52 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -38,6 +38,39 @@ export type ListSchemesParams = z.infer; const createTextBlock = (text: string) => ({ type: 'text', text }) as const; +export function parseSchemesFromXcodebuildListOutput(output: string): string[] { + const schemesMatch = output.match(/Schemes:([\s\S]*?)(?=\n\n|$)/); + if (!schemesMatch) { + throw new Error('No schemes found in the output'); + } + + return schemesMatch[1] + .trim() + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +export async function listSchemes( + params: ListSchemesParams, + executor: CommandExecutor, +): Promise { + const command = ['xcodebuild', '-list']; + + if (typeof params.projectPath === 'string') { + command.push('-project', params.projectPath); + } else { + command.push('-workspace', params.workspacePath!); + } + + const result = await executor(command, 'List Schemes', false); + if (!result.success) { + throw new Error(`Failed to list schemes: ${result.error}`); + } + + return parseSchemesFromXcodebuildListOutput(result.output); +} + /** * Business logic for listing schemes in a project or workspace. * Exported for direct testing and reuse. @@ -49,37 +82,11 @@ export async function listSchemesLogic( log('info', 'Listing schemes'); try { - // For listing schemes, we can't use executeXcodeBuild directly since it's not a standard action - // We need to create a custom command with -list flag - const command = ['xcodebuild', '-list']; - const hasProjectPath = typeof params.projectPath === 'string'; const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; const path = hasProjectPath ? params.projectPath : params.workspacePath; + const schemes = await listSchemes(params, executor); - if (hasProjectPath) { - command.push('-project', params.projectPath!); - } else { - command.push('-workspace', params.workspacePath!); - } - - const result = await executor(command, 'List Schemes', false); - - if (!result.success) { - return createTextResponse(`Failed to list schemes: ${result.error}`, true); - } - - // Extract schemes from the output - const schemesMatch = result.output.match(/Schemes:([\s\S]*?)(?=\n\n|$)/); - - if (!schemesMatch) { - return createTextResponse('No schemes found in the output', true); - } - - const schemeLines = schemesMatch[1].trim().split('\n'); - const schemes = schemeLines.map((line) => line.trim()).filter((line) => line); - - // Prepare next-step params with the first scheme if available let nextStepParams: Record> | undefined; let hintText = ''; @@ -118,6 +125,13 @@ export async function listSchemesLogic( }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + if ( + errorMessage.startsWith('Failed to list schemes:') || + errorMessage === 'No schemes found in the output' + ) { + return createTextResponse(errorMessage, true); + } + log('error', `Error listing schemes: ${errorMessage}`); return createTextResponse(`Error listing schemes: ${errorMessage}`, true); } diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index 9acbd0fb..4d93b43e 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -6,7 +6,7 @@ import { } from '../../../../test-utils/mock-executors.ts'; // Import the named exports and logic function -import { schema, handler, list_simsLogic } from '../list_sims.ts'; +import { schema, handler, list_simsLogic, listSimulators } from '../list_sims.ts'; describe('list_sims tool', () => { let callHistory: Array<{ @@ -40,6 +40,45 @@ describe('list_sims tool', () => { }); describe('Handler Behavior (Complete Literal Returns)', () => { + it('returns structured simulator records for setup flows', async () => { + const mockExecutor = async (command: string[]) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15', + udid: 'test-uuid-123', + isAvailable: true, + state: 'Shutdown', + }, + ], + }, + }), + error: undefined, + }); + } + + return createMockCommandResponse({ + success: true, + output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (test-uuid-123) (Shutdown)`, + error: undefined, + }); + }; + + const simulators = await listSimulators(mockExecutor); + expect(simulators).toEqual([ + { + runtime: 'iOS 17.0', + name: 'iPhone 15', + udid: 'test-uuid-123', + state: 'Shutdown', + }, + ]); + }); + it('should handle successful simulator listing', async () => { const mockJsonOutput = JSON.stringify({ devices: { diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index c4f10985..24f13f30 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -21,6 +21,13 @@ interface SimulatorDevice { runtime?: string; } +export interface ListedSimulator { + runtime: string; + name: string; + udid: string; + state: string; +} + interface SimulatorData { devices: Record; } @@ -99,87 +106,93 @@ function isSimulatorData(value: unknown): value is SimulatorData { return true; } -export async function list_simsLogic( - params: ListSimsParams, - executor: CommandExecutor, -): Promise { - log('info', 'Starting xcrun simctl list devices request'); - - try { - // Try JSON first for structured data - const jsonCommand = ['xcrun', 'simctl', 'list', 'devices', '--json']; - const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', false); +export async function listSimulators(executor: CommandExecutor): Promise { + const jsonCommand = ['xcrun', 'simctl', 'list', 'devices', '--json']; + const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', false); - if (!jsonResult.success) { - return { - content: [ - { - type: 'text', - text: `Failed to list simulators: ${jsonResult.error}`, - }, - ], - }; - } + if (!jsonResult.success) { + throw new Error(`Failed to list simulators: ${jsonResult.error}`); + } - // Parse JSON output - let jsonDevices: Record = {}; - try { - const parsedData: unknown = JSON.parse(jsonResult.output); - if (isSimulatorData(parsedData)) { - jsonDevices = parsedData.devices; - } - } catch { - log('warn', 'Failed to parse JSON output, falling back to text parsing'); + let jsonDevices: Record = {}; + try { + const parsedData: unknown = JSON.parse(jsonResult.output); + if (isSimulatorData(parsedData)) { + jsonDevices = parsedData.devices; } + } catch { + log('warn', 'Failed to parse JSON output, falling back to text parsing'); + } - // Fallback to text parsing for Apple simctl bugs (duplicate runtime IDs in iOS 26.0 beta) - const textCommand = ['xcrun', 'simctl', 'list', 'devices']; - const textResult = await executor(textCommand, 'List Simulators (Text)', false); - - const textDevices = textResult.success ? parseTextOutput(textResult.output) : []; + const textCommand = ['xcrun', 'simctl', 'list', 'devices']; + const textResult = await executor(textCommand, 'List Simulators (Text)', false); + const textDevices = textResult.success ? parseTextOutput(textResult.output) : []; - // Merge JSON and text devices, preferring JSON but adding any missing from text - const allDevices: Record = { ...jsonDevices }; - const jsonUUIDs = new Set(); + const allDevices: Record = { ...jsonDevices }; + const jsonUUIDs = new Set(); - // Collect all UUIDs from JSON - for (const runtime in jsonDevices) { - for (const device of jsonDevices[runtime]) { - if (device.isAvailable) { - jsonUUIDs.add(device.udid); - } + for (const runtime in jsonDevices) { + for (const device of jsonDevices[runtime]) { + if (device.isAvailable) { + jsonUUIDs.add(device.udid); } } + } - // Add devices from text that aren't in JSON (handles Apple's duplicate runtime ID bug) - for (const textDevice of textDevices) { - if (!jsonUUIDs.has(textDevice.udid)) { - const runtime = textDevice.runtime ?? 'Unknown Runtime'; - if (!allDevices[runtime]) { - allDevices[runtime] = []; - } - allDevices[runtime].push(textDevice); - log( - 'info', - `Added missing device from text parsing: ${textDevice.name} (${textDevice.udid})`, - ); + for (const textDevice of textDevices) { + if (!jsonUUIDs.has(textDevice.udid)) { + const runtime = textDevice.runtime ?? 'Unknown Runtime'; + if (!allDevices[runtime]) { + allDevices[runtime] = []; } + allDevices[runtime].push(textDevice); + log( + 'info', + `Added missing device from text parsing: ${textDevice.name} (${textDevice.udid})`, + ); } + } - // Format output - let responseText = 'Available iOS Simulators:\n\n'; + const listed: ListedSimulator[] = []; + for (const runtime in allDevices) { + const devices = allDevices[runtime].filter((d) => d.isAvailable); + for (const device of devices) { + listed.push({ + runtime, + name: device.name, + udid: device.udid, + state: device.state, + }); + } + } - for (const runtime in allDevices) { - const devices = allDevices[runtime].filter((d) => d.isAvailable); + return listed; +} +export async function list_simsLogic( + _params: ListSimsParams, + executor: CommandExecutor, +): Promise { + log('info', 'Starting xcrun simctl list devices request'); + + try { + const simulators = await listSimulators(executor); + + let responseText = 'Available iOS Simulators:\n\n'; + const grouped = new Map(); + for (const simulator of simulators) { + const runtimeGroup = grouped.get(simulator.runtime) ?? []; + runtimeGroup.push(simulator); + grouped.set(simulator.runtime, runtimeGroup); + } + + for (const [runtime, devices] of grouped.entries()) { if (devices.length === 0) continue; responseText += `${runtime}:\n`; - for (const device of devices) { responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`; } - responseText += '\n'; } @@ -206,6 +219,17 @@ export async function list_simsLogic( }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.startsWith('Failed to list simulators:')) { + return { + content: [ + { + type: 'text', + text: errorMessage, + }, + ], + }; + } + log('error', `Error listing simulators: ${errorMessage}`); return { content: [ diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts index 79c78586..f6cbcd88 100644 --- a/src/server/start-mcp-server.ts +++ b/src/server/start-mcp-server.ts @@ -23,6 +23,7 @@ import { shutdownXcodeToolsBridge } from '../integrations/xcode-tools-bridge/ind import { createStartupProfiler, getStartupProfileNowMs } from './startup-profiler.ts'; import { getConfig } from '../utils/config-store.ts'; import { getRegisteredWorkflows } from '../utils/tool-registry.ts'; +import { hydrateSentryDisabledEnvFromProjectConfig } from '../utils/sentry-config.ts'; /** * Start the MCP server. @@ -37,6 +38,8 @@ export async function startMcpServer(): Promise { // Clients can override via logging/setLevel MCP request setLogLevel('info'); + await hydrateSentryDisabledEnvFromProjectConfig(); + let stageStartMs = getStartupProfileNowMs(); initSentry({ mode: 'mcp' }); profiler.mark('initSentry', stageStartMs); diff --git a/src/utils/__tests__/config-store.test.ts b/src/utils/__tests__/config-store.test.ts index 90ceac4f..d9072c92 100644 --- a/src/utils/__tests__/config-store.test.ts +++ b/src/utils/__tests__/config-store.test.ts @@ -46,6 +46,7 @@ describe('config-store', () => { it('parses env values when provided', async () => { const env = { XCODEBUILDMCP_DEBUG: 'true', + XCODEBUILDMCP_SENTRY_DISABLED: 'true', INCREMENTAL_BUILDS_ENABLED: '1', XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS: '12345', XCODEBUILDMCP_DAP_LOG_EVENTS: 'true', @@ -59,6 +60,7 @@ describe('config-store', () => { const config = getConfig(); expect(config.debug).toBe(true); + expect(config.sentryDisabled).toBe(true); expect(config.incrementalBuildsEnabled).toBe(true); expect(config.dapRequestTimeoutMs).toBe(12345); expect(config.dapLogEvents).toBe(true); @@ -87,6 +89,15 @@ describe('config-store', () => { expect(config.dapRequestTimeoutMs).toBe(12345); }); + it('reads sentryDisabled from config file', async () => { + const yaml = ['schemaVersion: 1', 'sentryDisabled: true', ''].join('\n'); + + await initConfigStore({ cwd, fs: createFs(yaml) }); + + const config = getConfig(); + expect(config.sentryDisabled).toBe(true); + }); + it('resolves enabledWorkflows from overrides, config, then defaults', async () => { const yamlWithoutWorkflows = ['schemaVersion: 1', 'debug: false', ''].join('\n'); diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts index 46f9e12e..99284f69 100644 --- a/src/utils/__tests__/project-config.test.ts +++ b/src/utils/__tests__/project-config.test.ts @@ -5,6 +5,7 @@ import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts import { loadProjectConfig, persistActiveSessionDefaultsProfileToProjectConfig, + persistProjectConfigPatch, persistSessionDefaultsToProjectConfig, } from '../project-config.ts'; @@ -302,6 +303,78 @@ describe('project-config', () => { }); }); + describe('persistProjectConfigPatch', () => { + it('writes top-level setup fields and session defaults', async () => { + const { fs, writes } = createFsFixture({ exists: false }); + + await persistProjectConfigPatch({ + fs, + cwd, + patch: { + enabledWorkflows: ['simulator', 'ui-automation'], + debug: true, + sentryDisabled: true, + sessionDefaults: { + workspacePath: './MyApp.xcworkspace', + scheme: 'MyApp', + simulatorId: 'SIM-1', + }, + }, + deleteSessionDefaultKeys: ['projectPath'], + }); + + expect(writes.length).toBe(1); + const parsed = parseYaml(writes[0].content) as { + enabledWorkflows?: string[]; + debug?: boolean; + sentryDisabled?: boolean; + sessionDefaults?: Record; + }; + + expect(parsed.enabledWorkflows).toEqual(['simulator', 'ui-automation']); + expect(parsed.debug).toBe(true); + expect(parsed.sentryDisabled).toBe(true); + expect(parsed.sessionDefaults?.workspacePath).toBe('./MyApp.xcworkspace'); + expect(parsed.sessionDefaults?.projectPath).toBeUndefined(); + }); + + it('preserves unknown sections while patching setup fields', async () => { + const yaml = [ + 'schemaVersion: 1', + 'server:', + ' enabledWorkflows:', + ' - simulator', + 'sessionDefaults:', + ' projectPath: "./App.xcodeproj"', + '', + ].join('\n'); + const { fs, writes } = createFsFixture({ exists: true, readFile: yaml }); + + await persistProjectConfigPatch({ + fs, + cwd, + patch: { + debug: false, + enabledWorkflows: ['simulator'], + sessionDefaults: { + workspacePath: './App.xcworkspace', + }, + }, + deleteSessionDefaultKeys: ['projectPath'], + }); + + expect(writes.length).toBe(1); + const parsed = parseYaml(writes[0].content) as { + server?: { enabledWorkflows?: string[] }; + sessionDefaults?: Record; + }; + + expect(parsed.server?.enabledWorkflows).toEqual(['simulator']); + expect(parsed.sessionDefaults?.workspacePath).toBe('./App.xcworkspace'); + expect(parsed.sessionDefaults?.projectPath).toBeUndefined(); + }); + }); + describe('persistActiveSessionDefaultsProfileToProjectConfig', () => { it('persists active profile name', async () => { const { fs, writes } = createFsFixture({ exists: true, readFile: 'schemaVersion: 1\n' }); diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index 13c7e41d..3db06200 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -14,6 +14,7 @@ import { normalizeSessionDefaultsProfileName } from './session-defaults-profile. export type RuntimeConfigOverrides = Partial<{ enabledWorkflows: string[]; debug: boolean; + sentryDisabled: boolean; experimentalWorkflowDiscovery: boolean; disableSessionDefaults: boolean; disableXcodeAutoSync: boolean; @@ -36,6 +37,7 @@ export type RuntimeConfigOverrides = Partial<{ export type ResolvedRuntimeConfig = { enabledWorkflows: string[]; debug: boolean; + sentryDisabled: boolean; experimentalWorkflowDiscovery: boolean; disableSessionDefaults: boolean; disableXcodeAutoSync: boolean; @@ -67,6 +69,7 @@ type ConfigStoreState = { const DEFAULT_CONFIG: ResolvedRuntimeConfig = { enabledWorkflows: [], debug: false, + sentryDisabled: false, experimentalWorkflowDiscovery: false, disableSessionDefaults: false, disableXcodeAutoSync: false, @@ -167,6 +170,7 @@ function readEnvConfig(env: NodeJS.ProcessEnv): RuntimeConfigOverrides { ); setIfDefined(config, 'debug', parseBoolean(env.XCODEBUILDMCP_DEBUG)); + setIfDefined(config, 'sentryDisabled', parseBoolean(env.XCODEBUILDMCP_SENTRY_DISABLED)); setIfDefined( config, @@ -384,6 +388,13 @@ function resolveConfig(opts: { envConfig, fallback: DEFAULT_CONFIG.debug, }), + sentryDisabled: resolveFromLayers({ + key: 'sentryDisabled', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.sentryDisabled, + }), experimentalWorkflowDiscovery: resolveFromLayers({ key: 'experimentalWorkflowDiscovery', overrides: opts.overrides, diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index 64115078..1c6f3d41 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -45,6 +45,20 @@ export type PersistActiveSessionDefaultsProfileOptions = { profile?: string | null; }; +export type PersistProjectConfigPatchOptions = { + fs: FileSystemExecutor; + cwd: string; + patch: { + enabledWorkflows?: string[]; + debug?: boolean; + sentryDisabled?: boolean; + experimentalWorkflowDiscovery?: boolean; + disableSessionDefaults?: boolean; + sessionDefaults?: Partial; + }; + deleteSessionDefaultKeys?: (keyof SessionDefaults)[]; +}; + type PersistenceTargetOptions = { fs: FileSystemExecutor; configPath: string; @@ -342,3 +356,51 @@ export async function persistActiveSessionDefaultsProfileToProjectConfig( return { path: configPath }; } + +export async function persistProjectConfigPatch( + options: PersistProjectConfigPatchOptions, +): Promise<{ path: string }> { + const configDir = getConfigDir(options.cwd); + const configPath = getConfigPath(options.cwd); + + await options.fs.mkdir(configDir, { recursive: true }); + const baseConfig = await readBaseConfigForPersistence({ fs: options.fs, configPath }); + + const nextConfig: ProjectConfig = { + ...baseConfig, + schemaVersion: 1, + }; + + if (options.patch.enabledWorkflows !== undefined) { + nextConfig.enabledWorkflows = normalizeEnabledWorkflows(options.patch.enabledWorkflows); + } + + const topLevelPatch = removeUndefined({ + debug: options.patch.debug, + sentryDisabled: options.patch.sentryDisabled, + experimentalWorkflowDiscovery: options.patch.experimentalWorkflowDiscovery, + disableSessionDefaults: options.patch.disableSessionDefaults, + }); + + for (const [key, value] of Object.entries(topLevelPatch)) { + nextConfig[key] = value; + } + + if (options.patch.sessionDefaults) { + const patch = removeUndefined(options.patch.sessionDefaults as Record); + const nextSessionDefaults: Partial = { + ...(nextConfig.sessionDefaults ?? {}), + ...patch, + }; + + for (const key of options.deleteSessionDefaultKeys ?? []) { + delete nextSessionDefaults[key]; + } + + nextConfig.sessionDefaults = nextSessionDefaults; + } + + await options.fs.writeFile(configPath, stringifyYaml(nextConfig), 'utf8'); + + return { path: configPath }; +} diff --git a/src/utils/runtime-config-schema.ts b/src/utils/runtime-config-schema.ts index 8900ac19..6735ccb8 100644 --- a/src/utils/runtime-config-schema.ts +++ b/src/utils/runtime-config-schema.ts @@ -6,6 +6,7 @@ export const runtimeConfigFileSchema = z schemaVersion: z.literal(1).optional().default(1), enabledWorkflows: z.union([z.array(z.string()), z.string()]).optional(), debug: z.boolean().optional(), + sentryDisabled: z.boolean().optional(), experimentalWorkflowDiscovery: z.boolean().optional(), disableSessionDefaults: z.boolean().optional(), disableXcodeAutoSync: z.boolean().optional(), diff --git a/src/utils/sentry-config.ts b/src/utils/sentry-config.ts new file mode 100644 index 00000000..5062511a --- /dev/null +++ b/src/utils/sentry-config.ts @@ -0,0 +1,21 @@ +import { getDefaultFileSystemExecutor, type FileSystemExecutor } from './command.ts'; +import { loadProjectConfig } from './project-config.ts'; + +export async function hydrateSentryDisabledEnvFromProjectConfig(opts?: { + cwd?: string; + fs?: FileSystemExecutor; +}): Promise { + const envDisabled = + process.env.XCODEBUILDMCP_SENTRY_DISABLED === 'true' || process.env.SENTRY_DISABLED === 'true'; + if (envDisabled) { + return; + } + + const fs = opts?.fs ?? getDefaultFileSystemExecutor(); + const cwd = opts?.cwd ?? process.cwd(); + const result = await loadProjectConfig({ fs, cwd }); + + if (result.found && result.config.sentryDisabled === true) { + process.env.XCODEBUILDMCP_SENTRY_DISABLED = 'true'; + } +} diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 95c36135..fdd071c0 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -20,6 +20,7 @@ function createDefaultConfig( ): ResolvedRuntimeConfig { return { debug: false, + sentryDisabled: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false, disableSessionDefaults: false, diff --git a/src/visibility/__tests__/predicate-registry.test.ts b/src/visibility/__tests__/predicate-registry.test.ts index a082738c..78625df3 100644 --- a/src/visibility/__tests__/predicate-registry.test.ts +++ b/src/visibility/__tests__/predicate-registry.test.ts @@ -13,6 +13,7 @@ function createDefaultConfig( ): ResolvedRuntimeConfig { return { debug: false, + sentryDisabled: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false, disableSessionDefaults: false, From 4df06b8de7c98f2a64f764cad204ee99d7f80143 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 27 Feb 2026 22:27:37 +0000 Subject: [PATCH 2/5] fix(cli): deduplicate isInteractiveTTY and stop spinner on error Extract shared isInteractiveTTY() to prompts.ts and add try/catch to withSpinner so the spinner is stopped if the task throws, preventing garbled terminal output on error. --- src/cli/commands/init.ts | 6 +----- src/cli/commands/setup.ts | 22 ++++++++++++++-------- src/cli/interactive/prompts.ts | 6 +++++- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 6a8874dd..e2f9d3b9 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as clack from '@clack/prompts'; import { getResourceRoot } from '../../core/resource-root.ts'; -import { createPrompter, type Prompter } from '../interactive/prompts.ts'; +import { createPrompter, isInteractiveTTY, type Prompter } from '../interactive/prompts.ts'; type SkillType = 'mcp' | 'cli'; @@ -78,10 +78,6 @@ function resolveDestinationPath(inputPath: string): string { return path.resolve(expandHomePrefix(inputPath)); } -function isInteractiveTTY(): boolean { - return process.stdin.isTTY === true && process.stdout.isTTY === true; -} - async function promptConfirm(question: string): Promise { if (!isInteractiveTTY()) { return false; diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index c1122d44..ac5188b1 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -13,7 +13,12 @@ import { persistProjectConfigPatch, type ProjectConfig, } from '../../utils/project-config.ts'; -import { createPrompter, type Prompter, type SelectOption } from '../interactive/prompts.ts'; +import { + createPrompter, + isInteractiveTTY, + type Prompter, + type SelectOption, +} from '../interactive/prompts.ts'; import type { FileSystemExecutor } from '../../utils/FileSystemExecutor.ts'; import type { CommandExecutor } from '../../utils/CommandExecutor.ts'; import { createDoctorDependencies } from '../../mcp/tools/doctor/lib/doctor.deps.ts'; @@ -52,10 +57,6 @@ function showPromptHelp(helpText: string, quietOutput: boolean): void { clack.log.message(helpText); } -function isInteractiveTTY(): boolean { - return process.stdin.isTTY === true && process.stdout.isTTY === true; -} - async function withSpinner(opts: { isTTY: boolean; quietOutput: boolean; @@ -69,9 +70,14 @@ async function withSpinner(opts: { const s = clack.spinner(); s.start(opts.startMessage); - const result = await opts.task(); - s.stop(opts.stopMessage); - return result; + try { + const result = await opts.task(); + s.stop(opts.stopMessage); + return result; + } catch (error) { + s.stop(opts.startMessage); + throw error; + } } function valuesEqual(left: unknown, right: unknown): boolean { diff --git a/src/cli/interactive/prompts.ts b/src/cli/interactive/prompts.ts index d0736a44..a4389c26 100644 --- a/src/cli/interactive/prompts.ts +++ b/src/cli/interactive/prompts.ts @@ -136,8 +136,12 @@ function createTtyPrompter(): Prompter { }; } +export function isInteractiveTTY(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true; +} + export function createPrompter(): Prompter { - if (!process.stdin.isTTY || !process.stdout.isTTY) { + if (!isInteractiveTTY()) { return createNonInteractivePrompter(); } From 886ea50db2a7a8d2cdad21fb4f36758db8a46cf8 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 28 Feb 2026 20:21:57 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(logger):=20re?= =?UTF-8?q?name=20'warning'=20log=20level=20to=20'warn'=20and=20add=20norm?= =?UTF-8?q?alizeLogLevel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align internal log level naming with Sentry SDK conventions ('warn' instead of 'warning'). Add normalizeLogLevel() to safely map external level strings (including MCP protocol's 'warning') to internal LogLevel values. Also removes the CLI daemon logLevel passthrough (debug flag no longer overrides daemon log level) and filters 'log-level' from tool command args. --- src/cli.ts | 3 +- src/cli/cli-tool-catalog.ts | 16 ++-------- src/cli/commands/daemon.ts | 2 +- src/cli/register-tool-commands.ts | 2 +- src/cli/yargs-app.ts | 2 +- src/daemon.ts | 29 ++++--------------- src/daemon/daemon-server.ts | 6 ++-- src/mcp/tools/logging/stop_device_log_cap.ts | 2 +- .../tools/project-discovery/discover_projs.ts | 5 +--- src/mcp/tools/simulator/build_run_sim.ts | 6 ++-- src/mcp/tools/simulator/build_sim.ts | 2 +- src/mcp/tools/simulator/get_sim_app_path.ts | 2 +- src/mcp/tools/simulator/install_app_sim.ts | 2 +- src/mcp/tools/simulator/test_sim.ts | 2 +- src/mcp/tools/ui-automation/screenshot.ts | 22 +++++++------- src/runtime/tool-catalog.ts | 2 +- src/server/bootstrap.ts | 7 +++-- src/server/start-mcp-server.ts | 2 +- src/utils/__tests__/logger.test.ts | 2 +- src/utils/config-store.ts | 10 +++---- src/utils/infer-platform.ts | 6 ++-- src/utils/log_capture.ts | 2 +- src/utils/logger.ts | 18 ++++++++++-- src/utils/platform-detection.ts | 2 +- src/utils/project-config.ts | 4 +-- src/utils/simulator-defaults-refresh.ts | 2 +- src/utils/tool-registry.ts | 2 +- src/utils/validation.ts | 10 +++---- src/utils/xcode-state-reader.ts | 8 ++--- src/utils/xcode-state-watcher.ts | 6 ++-- src/utils/xcode.ts | 2 +- src/utils/xcodemake.ts | 2 +- 32 files changed, 87 insertions(+), 103 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 57863edf..ffa1f1e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,7 +48,7 @@ async function buildLightweightYargsApp(): Promise { socketPath: defaultSocketPath, workspaceRoot, cliExposedWorkflowIds, - logLevel: result.runtime.config.debug ? 'info' : undefined, discoveryMode, }); diff --git a/src/cli/cli-tool-catalog.ts b/src/cli/cli-tool-catalog.ts index 235584a7..cbe0cd21 100644 --- a/src/cli/cli-tool-catalog.ts +++ b/src/cli/cli-tool-catalog.ts @@ -15,7 +15,6 @@ interface BuildCliToolCatalogOptions { socketPath: string; workspaceRoot: string; cliExposedWorkflowIds: string[]; - logLevel?: string; discoveryMode?: 'none' | 'quick'; } @@ -50,16 +49,6 @@ function jsonSchemaToToolSchemaShape(inputSchema: unknown): ToolSchemaShape { return shape; } -function buildDaemonEnvOverrides(opts: BuildCliToolCatalogOptions): Record { - const env: Record = {}; - - if (opts.logLevel) { - env.XCODEBUILDMCP_DAEMON_LOG_LEVEL = opts.logLevel; - } - - return env; -} - async function invokeRemoteToolOneShot( remoteToolName: string, args: Record, @@ -123,11 +112,10 @@ async function loadDaemonBackedXcodeProxyTools( startDaemonBackground({ socketPath: opts.socketPath, workspaceRoot: opts.workspaceRoot, - env: buildDaemonEnvOverrides(opts), }); } catch (startError) { const message = startError instanceof Error ? startError.message : String(startError); - log('warning', `[xcode-ide] Failed to start daemon in background: ${message}`); + log('warn', `[xcode-ide] Failed to start daemon in background: ${message}`); } return []; } @@ -149,7 +137,7 @@ async function loadDaemonBackedXcodeProxyTools( } catch (error) { const message = error instanceof Error ? error.message : String(error); if (quickMode) { - log('warning', `[xcode-ide] CLI daemon-backed bridge discovery failed: ${message}`); + log('warn', `[xcode-ide] CLI daemon-backed bridge discovery failed: ${message}`); } else { log('debug', `[xcode-ide] CLI cached bridge discovery skipped: ${message}`); } diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 8acc3079..9a71fbcb 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -48,7 +48,7 @@ export function registerDaemonCommands(app: Argv, opts: DaemonCommandsOptions): 'alert', 'critical', 'error', - 'warning', + 'warn', 'notice', 'info', 'debug', diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index 8f020506..f7fbe762 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -162,7 +162,7 @@ function registerToolSubcommand( // Convert CLI argv to tool params (kebab-case -> camelCase) // Filter out internal CLI options before converting - const internalKeys = new Set(['json', 'output', 'style', 'socket', '_', '$0']); + const internalKeys = new Set(['json', 'output', 'style', 'socket', 'log-level', '_', '$0']); const flagArgs: Record = {}; for (const [key, value] of Object.entries(argv as Record)) { if (!internalKeys.has(key)) { diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index aefe37d1..e6139ae6 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -44,7 +44,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { .option('log-level', { type: 'string', describe: 'Set log verbosity level', - choices: ['none', 'error', 'warning', 'info', 'debug'] as const, + choices: ['none', 'error', 'warn', 'info', 'debug'] as const, default: 'none', }) .option('style', { diff --git a/src/daemon.ts b/src/daemon.ts index 75d2ed8a..6c5468e0 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -19,7 +19,7 @@ import { removeDaemonRegistryEntry, cleanupWorkspaceDaemonFiles, } from './daemon/daemon-registry.ts'; -import { log, setLogFile, setLogLevel, type LogLevel } from './utils/logger.ts'; +import { log, normalizeLogLevel, setLogFile, setLogLevel } from './utils/logger.ts'; import { version } from './version.ts'; import { DAEMON_IDLE_TIMEOUT_ENV_KEY, @@ -102,29 +102,12 @@ function ensureLogDir(logPath: string): void { } } -function resolveLogLevel(): LogLevel | null { - const raw = process.env.XCODEBUILDMCP_DAEMON_LOG_LEVEL?.trim().toLowerCase(); +function resolveLogLevel(): ReturnType { + const raw = process.env.XCODEBUILDMCP_DAEMON_LOG_LEVEL; if (!raw) { return null; } - - const knownLevels: LogLevel[] = [ - 'none', - 'emergency', - 'alert', - 'critical', - 'error', - 'warning', - 'notice', - 'info', - 'debug', - ]; - - if (knownLevels.includes(raw as LogLevel)) { - return raw as LogLevel; - } - - return null; + return normalizeLogLevel(raw); } async function main(): Promise { @@ -315,7 +298,7 @@ async function main(): Promise { // Force exit if server doesn't close in time setTimeout(() => { - log('warning', '[Daemon] Forced shutdown after timeout'); + log('warn', '[Daemon] Forced shutdown after timeout'); cleanupWorkspaceDaemonFiles(workspaceKey); void flushAndCloseSentry(1000).finally(() => { process.exit(1); @@ -408,7 +391,7 @@ async function main(): Promise { setImmediate(() => { void enrichSentryMetadata().catch((error) => { const message = error instanceof Error ? error.message : String(error); - log('warning', `[Daemon] Failed to enrich Sentry metadata: ${message}`); + log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`); }); }); }); diff --git a/src/daemon/daemon-server.ts b/src/daemon/daemon-server.ts index 07f0dab9..722f15f5 100644 --- a/src/daemon/daemon-server.ts +++ b/src/daemon/daemon-server.ts @@ -212,7 +212,7 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { } }, (err) => { - log('warning', `[Daemon] Frame parse error: ${err.message}`); + log('warn', `[Daemon] Frame parse error: ${err.message}`); }, ); @@ -221,12 +221,12 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { log('info', '[Daemon] Client disconnected'); }); socket.on('error', (err) => { - log('warning', `[Daemon] Socket error: ${err.message}`); + log('warn', `[Daemon] Socket error: ${err.message}`); }); }); server.on('error', (err) => { - log('warning', `[Daemon] Server error: ${err.message}`); + log('warn', `[Daemon] Server error: ${err.message}`); }); server.on('close', () => { void xcodeIdeService.disconnect(); diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index 4553cefd..e3a2fdc9 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -35,7 +35,7 @@ export async function stop_device_log_capLogic( const session = activeDeviceLogSessions.get(logSessionId); if (!session) { - log('warning', `Device log session not found: ${logSessionId}`); + log('warn', `Device log session not found: ${logSessionId}`); return { content: [ { diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index e11108e5..c6c3c70b 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -131,10 +131,7 @@ async function _findProjectsRecursive( if (code === 'EPERM' || code === 'EACCES') { log('debug', `Permission denied scanning directory: ${currentDirAbs}`); } else { - log( - 'warning', - `Error scanning directory ${currentDirAbs}: ${message} (Code: ${code ?? 'N/A'})`, - ); + log('warn', `Error scanning directory ${currentDirAbs}: ${message} (Code: ${code ?? 'N/A'})`); } } } diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index b8adab08..0019fdbc 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -91,7 +91,7 @@ async function _handleSimulatorBuildLogic( // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( - 'warning', + 'warn', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } @@ -270,7 +270,7 @@ export async function build_run_simLogic( } if (uuidResult.warning) { - log('warning', uuidResult.warning); + log('warn', uuidResult.warning); } const simulatorId = uuidResult.uuid; @@ -364,7 +364,7 @@ export async function build_run_simLogic( } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('warning', `Warning: Could not open Simulator app: ${errorMessage}`); + log('warn', `Warning: Could not open Simulator app: ${errorMessage}`); // Don't fail the whole operation for this } diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index 3eef8b42..27eefbe1 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -86,7 +86,7 @@ async function _handleSimulatorBuildLogic( // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( - 'warning', + 'warn', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index 27d103cb..1e45dd34 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -99,7 +99,7 @@ export async function get_sim_app_pathLogic( // Log warning if useLatestOS is provided with simulatorId if (simulatorId && params.useLatestOS !== undefined) { log( - 'warning', + 'warn', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index ba0b297d..5ba3b47b 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -80,7 +80,7 @@ export async function install_app_simLogic( bundleId = bundleIdResult.output.trim(); } } catch (error) { - log('warning', `Could not extract bundle ID from app: ${error}`); + log('warn', `Could not extract bundle ID from app: ${error}`); } return { diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index 5f4bd2b3..ebe51dea 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -86,7 +86,7 @@ export async function test_simLogic( // Log warning if useLatestOS is provided with simulatorId if (params.simulatorId && params.useLatestOS !== undefined) { log( - 'warning', + 'warn', `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, ); } diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index c129fa25..5e4000cd 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -108,10 +108,10 @@ export async function getDeviceNameForSimulatorId( } } } - log('warning', `${LOG_PREFIX}: Could not find device name for ${simulatorId}`); + log('warn', `${LOG_PREFIX}: Could not find device name for ${simulatorId}`); return null; } catch (error) { - log('warning', `${LOG_PREFIX}: Failed to get device name: ${error}`); + log('warn', `${LOG_PREFIX}: Failed to get device name: ${error}`); return null; } } @@ -129,7 +129,7 @@ export async function detectLandscapeMode( // If no device name available, skip orientation detection to avoid incorrect rotation // This is safer than guessing, as we don't know if it's iPhone or iPad if (!deviceName) { - log('warning', `${LOG_PREFIX}: No device name available, skipping orientation detection`); + log('warn', `${LOG_PREFIX}: No device name available, skipping orientation detection`); return false; } const swiftCode = getWindowDetectionSwiftCode(deviceName); @@ -149,10 +149,10 @@ export async function detectLandscapeMode( return isLandscape; } } - log('warning', `${LOG_PREFIX}: Could not detect window orientation, assuming portrait`); + log('warn', `${LOG_PREFIX}: Could not detect window orientation, assuming portrait`); return false; } catch (error) { - log('warning', `${LOG_PREFIX}: Orientation detection failed: ${error}`); + log('warn', `${LOG_PREFIX}: Orientation detection failed: ${error}`); return false; } } @@ -170,7 +170,7 @@ export async function rotateImage( const result = await executor(rotateArgs, `${LOG_PREFIX}: rotate image`, false); return result.success; } catch (error) { - log('warning', `${LOG_PREFIX}: Image rotation failed: ${error}`); + log('warn', `${LOG_PREFIX}: Image rotation failed: ${error}`); return false; } } @@ -239,7 +239,7 @@ export async function screenshotLogic( log('info', `${LOG_PREFIX}/screenshot: Landscape mode detected, rotating +90°`); const rotated = await rotateImage(screenshotPath, 90, executor); if (!rotated) { - log('warning', `${LOG_PREFIX}/screenshot: Rotation failed, continuing with original`); + log('warn', `${LOG_PREFIX}/screenshot: Rotation failed, continuing with original`); } } @@ -262,7 +262,7 @@ export async function screenshotLogic( const optimizeResult = await executor(optimizeArgs, `${LOG_PREFIX}: optimize image`, false); if (!optimizeResult.success) { - log('warning', `${LOG_PREFIX}/screenshot: Image optimization failed, using original PNG`); + log('warn', `${LOG_PREFIX}/screenshot: Image optimization failed, using original PNG`); if (returnFormat === 'base64') { // Fallback to original PNG if optimization fails const base64Image = await fileSystemExecutor.readFile(screenshotPath, 'base64'); @@ -271,7 +271,7 @@ export async function screenshotLogic( try { await fileSystemExecutor.rm(screenshotPath); } catch (err) { - log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); + log('warn', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); } return { @@ -298,7 +298,7 @@ export async function screenshotLogic( await fileSystemExecutor.rm(screenshotPath); await fileSystemExecutor.rm(optimizedPath); } catch (err) { - log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temporary files: ${err}`); + log('warn', `${LOG_PREFIX}/screenshot: Failed to delete temporary files: ${err}`); } // Return the optimized image (JPEG format, smaller size) @@ -312,7 +312,7 @@ export async function screenshotLogic( try { await fileSystemExecutor.rm(screenshotPath); } catch (err) { - log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); + log('warn', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); } return createTextResponse(`Screenshot captured: ${optimizedPath} (image/jpeg)`); diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index 834708d3..c2a1e9f1 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -165,7 +165,7 @@ export async function buildToolCatalogFromManifest(opts: { toolModule = await importToolModule(toolManifest.module); moduleCache.set(toolId, toolModule); } catch (err) { - log('warning', `Failed to import tool module ${toolManifest.module}: ${err}`); + log('warn', `Failed to import tool module ${toolManifest.module}: ${err}`); continue; } } diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 7aaa2c4d..292ad189 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { registerResources } from '../core/resources.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; -import { log, setLogLevel, type LogLevel } from '../utils/logger.ts'; +import { log, normalizeLogLevel, setLogLevel } from '../utils/logger.ts'; import type { RuntimeConfigOverrides } from '../utils/config-store.ts'; import { getRegisteredWorkflows, registerWorkflowsFromManifest } from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; @@ -35,7 +35,10 @@ export async function bootstrapServer( server.server.setRequestHandler(SetLevelRequestSchema, async (request) => { const { level } = request.params; - setLogLevel(level as LogLevel); + const normalized = normalizeLogLevel(level); + if (normalized) { + setLogLevel(normalized); + } log('info', `Client requested log level: ${level}`); return {}; }); diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts index f6cbcd88..dafb7af6 100644 --- a/src/server/start-mcp-server.ts +++ b/src/server/start-mcp-server.ts @@ -71,7 +71,7 @@ export async function startMcpServer(): Promise { void bootstrap.runDeferredInitialization().catch((error) => { log( - 'warning', + 'warn', `Deferred bootstrap initialization failed: ${error instanceof Error ? error.message : String(error)}`, ); }); diff --git a/src/utils/__tests__/logger.test.ts b/src/utils/__tests__/logger.test.ts index d728b13a..940c5d52 100644 --- a/src/utils/__tests__/logger.test.ts +++ b/src/utils/__tests__/logger.test.ts @@ -16,7 +16,7 @@ describe('logger sentry capture policy', () => { it('maps internal levels to Sentry log levels', () => { expect(__mapLogLevelToSentryForTests('emergency')).toBe('fatal'); - expect(__mapLogLevelToSentryForTests('warning')).toBe('warn'); + expect(__mapLogLevelToSentryForTests('warn')).toBe('warn'); expect(__mapLogLevelToSentryForTests('notice')).toBe('info'); expect(__mapLogLevelToSentryForTests('error')).toBe('error'); }); diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index 3db06200..f9361327 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -139,7 +139,7 @@ function parseDebuggerBackend(value: string | undefined): DebuggerBackendKind | const normalized = value.trim().toLowerCase(); if (normalized === 'lldb' || normalized === 'lldb-cli') return 'lldb-cli'; if (normalized === 'dap') return 'dap'; - log('warning', `Unsupported debugger backend '${value}', falling back to defaults.`); + log('warn', `Unsupported debugger backend '${value}', falling back to defaults.`); return undefined; } @@ -528,12 +528,12 @@ export async function initConfigStore(opts: { } else if ('error' in result) { const errorMessage = result.error instanceof Error ? result.error.message : String(result.error); - log('warning', `Failed to read or parse project config at ${result.path}. ${errorMessage}`); - log('warning', '[infra/config-store] project config read/parse failed', { sentry: true }); + log('warn', `Failed to read or parse project config at ${result.path}. ${errorMessage}`); + log('warn', '[infra/config-store] project config read/parse failed', { sentry: true }); } } catch (error) { - log('warning', `Failed to load project config from ${opts.cwd}. ${error}`); - log('warning', `[infra/config-store] project config load threw (${getErrorKind(error)})`, { + log('warn', `Failed to load project config from ${opts.cwd}. ${error}`); + log('warn', `[infra/config-store] project config load threw (${getErrorKind(error)})`, { sentry: true, }); } diff --git a/src/utils/infer-platform.ts b/src/utils/infer-platform.ts index c3d3cba5..28b596ad 100644 --- a/src/utils/infer-platform.ts +++ b/src/utils/infer-platform.ts @@ -180,7 +180,7 @@ async function inferPlatformFromSimctl( ); if (!result.success) { - log('warning', `[Platform Inference] simctl failed: ${result.error ?? 'Unknown error'}`); + log('warn', `[Platform Inference] simctl failed: ${result.error ?? 'Unknown error'}`); return null; } @@ -188,12 +188,12 @@ async function inferPlatformFromSimctl( try { parsed = JSON.parse(result.output); } catch { - log('warning', `[Platform Inference] Failed to parse simctl JSON output`); + log('warn', `[Platform Inference] Failed to parse simctl JSON output`); return null; } if (!parsed || typeof parsed !== 'object' || !('devices' in parsed)) { - log('warning', `[Platform Inference] simctl JSON missing devices`); + log('warn', `[Platform Inference] simctl JSON missing devices`); return null; } diff --git a/src/utils/log_capture.ts b/src/utils/log_capture.ts index 3e32c58d..2074dfd8 100644 --- a/src/utils/log_capture.ts +++ b/src/utils/log_capture.ts @@ -244,7 +244,7 @@ export async function stopLogCapture( ): Promise<{ logContent: string; error?: string }> { const session = activeLogSessions.get(logSessionId); if (!session) { - log('warning', `Log session not found: ${logSessionId}`); + log('warn', `Log session not found: ${logSessionId}`); return { logContent: '', error: `Log capture session not found: ${logSessionId}` }; } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 87afaa75..dc892592 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -36,7 +36,7 @@ const LOG_LEVELS = { alert: 1, critical: 2, error: 3, - warning: 4, + warn: 4, notice: 5, info: 6, debug: 7, @@ -112,7 +112,7 @@ function mapLogLevelToSentry(level: string): SentryLogLevel { case 'critical': case 'error': return 'error'; - case 'warning': + case 'warn': return 'warn'; case 'debug': return 'debug'; @@ -128,6 +128,20 @@ export function __mapLogLevelToSentryForTests(level: string): SentryLogLevel { return mapLogLevelToSentry(level); } +/** + * Normalize an external log level string to the internal LogLevel type. + * Handles the MCP protocol's 'warning' (mapped to internal 'warn') and + * validates against known levels. Returns null for unrecognized values. + */ +export function normalizeLogLevel(raw: string): LogLevel | null { + const lower = raw.trim().toLowerCase(); + const mapped = lower === 'warning' ? 'warn' : lower; + if (mapped in LOG_LEVELS) { + return mapped as LogLevel; + } + return null; +} + /** * Set the minimum log level for client-requested filtering * @param level The minimum log level to output diff --git a/src/utils/platform-detection.ts b/src/utils/platform-detection.ts index 61a4918f..cf0231a9 100644 --- a/src/utils/platform-detection.ts +++ b/src/utils/platform-detection.ts @@ -118,7 +118,7 @@ export async function detectPlatformFromScheme( return { platform, sdkroot, supportedPlatforms }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('warning', `[Platform Detection] ${errorMessage}`); + log('warn', `[Platform Detection] ${errorMessage}`); return { platform: null, sdkroot: null, diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index 1c6f3d41..bb470971 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -113,7 +113,7 @@ function tryFileUrlToPath(value: string): string | null { try { return fileURLToPath(value); } catch (error) { - log('warning', `Failed to parse file URL path: ${value}. ${String(error)}`); + log('warn', `Failed to parse file URL path: ${value}. ${String(error)}`); return null; } } @@ -245,7 +245,7 @@ async function readBaseConfigForPersistence( } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log( - 'warning', + 'warn', `Failed to read or parse project config at ${options.configPath}. Overwriting with new config. ${errorMessage}`, ); return { schemaVersion: 1 }; diff --git a/src/utils/simulator-defaults-refresh.ts b/src/utils/simulator-defaults-refresh.ts index 1aa8b02c..1cbad03a 100644 --- a/src/utils/simulator-defaults-refresh.ts +++ b/src/utils/simulator-defaults-refresh.ts @@ -110,7 +110,7 @@ async function refreshSimulatorDefaults( } } catch (error) { log( - 'warning', + 'warn', `[Session] Background simulator defaults refresh failed (${options.reason}): ${String(error)}`, ); } diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index c5cdcffa..4da2a9b4 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -110,7 +110,7 @@ export async function applyWorkflowSelectionFromManifest( toolModule = await importToolModule(toolManifest.module); moduleCache.set(toolId, toolModule); } catch (err) { - log('warning', `Failed to import tool module ${toolManifest.module}: ${err}`); + log('warn', `Failed to import tool module ${toolManifest.module}: ${err}`); continue; } } diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 33a3682f..55922674 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -57,7 +57,7 @@ export function validateRequiredParam( helpfulMessage = `Required parameter '${paramName}' is missing. Please provide a value for this parameter.`, ): ValidationResult { if (paramValue === undefined || paramValue === null) { - log('warning', `Required parameter '${paramName}' is missing`); + log('warn', `Required parameter '${paramName}' is missing`); return { isValid: false, errorResponse: createTextResponse(helpfulMessage, true), @@ -81,7 +81,7 @@ export function validateAllowedValues( ): ValidationResult { if (!allowedValues.includes(paramValue)) { log( - 'warning', + 'warn', `Parameter '${paramName}' has invalid value '${paramValue}'. Allowed values: ${allowedValues.join( ', ', )}`, @@ -112,7 +112,7 @@ export function validateCondition( ): ValidationResult { if (!condition) { if (logWarning) { - log('warning', message); + log('warn', message); } return { isValid: false, @@ -164,7 +164,7 @@ export function validateAtLeastOneParam( (param1Value === undefined || param1Value === null) && (param2Value === undefined || param2Value === null) ) { - log('warning', `At least one of '${param1Name}' or '${param2Name}' must be provided`); + log('warn', `At least one of '${param1Name}' or '${param2Name}' must be provided`); return { isValid: false, errorResponse: createTextResponse( @@ -191,7 +191,7 @@ export function validateEnumParam( ): ValidationResult { if (!allowedValues.includes(paramValue)) { log( - 'warning', + 'warn', `Parameter '${paramName}' has invalid value '${paramValue}'. Allowed values: ${allowedValues.join( ', ', )}`, diff --git a/src/utils/xcode-state-reader.ts b/src/utils/xcode-state-reader.ts index d9c4994a..16405e81 100644 --- a/src/utils/xcode-state-reader.ts +++ b/src/utils/xcode-state-reader.ts @@ -113,7 +113,7 @@ export async function findXcodeStateFile( // Get current username const userResult = await executor(['whoami'], 'Get username', false); if (!userResult.success) { - log('warning', `[xcode-state] Failed to get username: ${userResult.error}`); + log('warn', `[xcode-state] Failed to get username: ${userResult.error}`); return undefined; } const username = userResult.output.trim(); @@ -253,7 +253,7 @@ export async function lookupSimulatorName( ); if (!result.success) { - log('warning', `[xcode-state] Failed to list simulators: ${result.error}`); + log('warn', `[xcode-state] Failed to list simulators: ${result.error}`); return undefined; } @@ -270,7 +270,7 @@ export async function lookupSimulatorName( } } } catch (e) { - log('warning', `[xcode-state] Failed to parse simulator list: ${e}`); + log('warn', `[xcode-state] Failed to parse simulator list: ${e}`); } return undefined; @@ -325,7 +325,7 @@ export async function readXcodeIdeState(ctx: XcodeStateReaderContext): Promise { state.debounceTimer = null; processFileChange().catch((e) => { - log('warning', `[xcode-watcher] Error processing file change: ${e}`); + log('warn', `[xcode-watcher] Error processing file change: ${e}`); }); }, DEBOUNCE_MS); } @@ -240,7 +240,7 @@ export async function startXcodeStateWatcher(options: StartWatcherOptions = {}): state.watcher.on('error', (error: unknown) => { const message = error instanceof Error ? error.message : String(error); - log('warning', `[xcode-watcher] Watcher error: ${message}`); + log('warn', `[xcode-watcher] Watcher error: ${message}`); }); return true; diff --git a/src/utils/xcode.ts b/src/utils/xcode.ts index b400ac27..e5d10165 100644 --- a/src/utils/xcode.ts +++ b/src/utils/xcode.ts @@ -56,7 +56,7 @@ export function constructDestinationString( // Throw error as specific simulator is needed unless it's a generic build action // Allow fallback for generic simulator builds if needed, but generally require specifics for build/run log( - 'warning', + 'warn', `Constructing generic destination for ${platform} without name or ID. This might not be specific enough.`, ); // Example: return `platform=${platform},name=Any ${platform} Device`; // Or similar generic target diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index 3d9f8954..0cb03eff 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -156,7 +156,7 @@ export async function isXcodemakeAvailable(): Promise { log('info', 'xcodemake not found in PATH, attempting to download...'); const installed = await installXcodemake(); if (!installed) { - log('warning', 'xcodemake installation failed'); + log('warn', 'xcodemake installation failed'); return false; } From e58435c37b845c5a0e938b94e28e9a352a240616 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 28 Feb 2026 20:22:15 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20fix(setup):=20show=20debug-g?= =?UTF-8?q?ated=20workflows=20when=20existing=20config=20enables=20debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass existing project config into workflow option evaluation so that debug-gated workflows (e.g. doctor) appear in the setup wizard when the user's config already has debug: true. --- src/cli/commands/__tests__/setup.test.ts | 93 ++++++++++++++++++++++++ src/cli/commands/setup.ts | 10 ++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index e76c45ac..189bf49b 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -124,6 +124,7 @@ describe('setup command', () => { }; expect(parsed.enabledWorkflows?.length).toBeGreaterThan(0); + expect(parsed.enabledWorkflows).not.toContain('doctor'); expect(parsed.debug).toBe(false); expect(parsed.sentryDisabled).toBe(false); expect(parsed.sessionDefaults?.workspacePath).toBe('App.xcworkspace'); @@ -131,6 +132,98 @@ describe('setup command', () => { expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1'); }); + it('shows debug-gated workflows when existing config enables debug', async () => { + let storedConfig = 'schemaVersion: 1\ndebug: true\n'; + let offeredWorkflowIds: string[] = []; + + 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: '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 prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + offeredWorkflowIds = opts.options.map((option) => String(option.value)); + return opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + debug?: boolean; + enabledWorkflows?: string[]; + }; + + expect(parsed.debug).toBe(true); + expect(offeredWorkflowIds).toContain('doctor'); + }); + it('fails fast when Xcode command line tools are unavailable', async () => { const failingExecutor: CommandExecutor = async (command) => { if (command[0] === 'xcodebuild') { diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index ac5188b1..00a660b1 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -118,7 +118,10 @@ function normalizeExistingDefaults(config?: ProjectConfig): { }; } -function getWorkflowOptions(debug: boolean): WorkflowManifestEntry[] { +function getWorkflowOptions( + debug: boolean, + existingConfig?: ProjectConfig, +): WorkflowManifestEntry[] { const manifest = loadManifest(); const config = getConfig(); @@ -126,6 +129,7 @@ function getWorkflowOptions(debug: boolean): WorkflowManifestEntry[] { runtime: 'mcp' as const, config: { ...config, + ...existingConfig, debug, }, runningUnderXcode: false, @@ -197,11 +201,12 @@ function getChangedFields( async function selectWorkflowIds(opts: { debug: boolean; + existingConfig?: ProjectConfig; existingEnabledWorkflows: string[]; prompter: Prompter; quietOutput: boolean; }): Promise { - const workflows = getWorkflowOptions(opts.debug); + const workflows = getWorkflowOptions(opts.debug, opts.existingConfig); if (workflows.length === 0) { return []; } @@ -427,6 +432,7 @@ async function collectSetupSelection( const enabledWorkflows = await selectWorkflowIds({ debug, + existingConfig, existingEnabledWorkflows: existingConfig?.enabledWorkflows ?? [], prompter: deps.prompter, quietOutput: deps.quietOutput, From e00170d9bbe862e8615533df30c798ea9c693a55 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 28 Feb 2026 20:22:33 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20debug=20flag?= =?UTF-8?q?=20investigation=20and=20reorder=20example=20config=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../debug-flag-investigation-2026-02-28.md | 85 +++++++++++++++++++ .../iOS_Calculator/.xcodebuildmcp/config.yaml | 4 +- 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 docs/dev/debug-flag-investigation-2026-02-28.md diff --git a/docs/dev/debug-flag-investigation-2026-02-28.md b/docs/dev/debug-flag-investigation-2026-02-28.md new file mode 100644 index 00000000..343c993e --- /dev/null +++ b/docs/dev/debug-flag-investigation-2026-02-28.md @@ -0,0 +1,85 @@ +# Investigation: DEBUG flag runtime behavior + +## Summary +The `debug` flag is a runtime configuration value (from config/env/overrides), not a compile-time constant. Enabling it mainly changes **tool/workflow visibility** (doctor workflow + bridge debug tools), adds a limited **CLI daemon log-level override**, and tags telemetry context; it does **not** broadly switch core execution paths. + +## Symptoms +- It was unclear whether `DEBUG` means logging-only or broader behavior changes. +- Docs mention debug logging and doctor exposure, but runtime impact was not clearly mapped end-to-end. + +## Investigation Log + +### 2026-02-28 / Phase 2 - Config source and precedence +**Hypothesis:** `debug` comes from multiple sources with precedence rules. +**Findings:** `debug` is parsed from `XCODEBUILDMCP_DEBUG`, accepted in config schema, and resolved via layered precedence: overrides > config file > env > defaults. +**Evidence:** `src/utils/config-store.ts:172`, `src/utils/config-store.ts:255-263`, `src/utils/config-store.ts:384`, `src/utils/runtime-config-schema.ts:9`, `src/utils/__tests__/config-store.test.ts:44-91` +**Conclusion:** Confirmed. + +### 2026-02-28 / Phase 3 - Predicate wiring and exposure filtering +**Hypothesis:** `debug` drives predicate-based workflow/tool visibility. +**Findings:** `debugEnabled` predicate is `ctx.config.debug`; workflow and tool visibility both run predicate evaluation; MCP registration and CLI/daemon catalogs use that exposure filtering. +**Evidence:** `src/visibility/predicate-registry.ts:16`, `src/visibility/exposure.ts:39`, `src/visibility/exposure.ts:64`, `src/utils/tool-registry.ts:85`, `src/utils/tool-registry.ts:100`, `src/runtime/tool-catalog.ts:143`, `src/runtime/tool-catalog.ts:159`, `src/server/bootstrap.ts:84-91`, `src/visibility/__tests__/exposure.test.ts:86-112,273-328`, `src/visibility/__tests__/predicate-registry.test.ts:42-55` +**Conclusion:** Confirmed. + +### 2026-02-28 / Phase 3 - Which workflows/tools are actually gated +**Hypothesis:** Only specific surfaces are debug-gated. +**Findings:** +- `doctor` workflow is `autoInclude: true` + `debugEnabled` predicate. +- `xcode_tools_bridge_{status,sync,disconnect}` tools are debug-gated. +- `xcode-ide` workflow itself is not debug-gated (uses `hideWhenXcodeAgentMode`). +**Evidence:** `manifests/workflows/doctor.yaml:5-8`, `manifests/tools/xcode_tools_bridge_status.yaml:7-8`, `manifests/tools/xcode_tools_bridge_sync.yaml:7-8`, `manifests/tools/xcode_tools_bridge_disconnect.yaml:7-8`, `manifests/workflows/xcode-ide.yaml:6-13`, `src/core/manifest/__tests__/load-manifest.test.ts:79-106` +**Conclusion:** Confirmed. + +### 2026-02-28 / Phase 3 - Doctor tool vs doctor resource behavior +**Hypothesis:** DEBUG gates the doctor tool but not the doctor resource. +**Findings:** +- Tool `doctor` is attached to debug-gated `doctor` workflow. +- Resource registry includes `doctor` resource unconditionally. +- Doctor resource directly calls doctor logic without debug predicate check. +**Evidence:** `manifests/workflows/doctor.yaml:8-10`, `manifests/tools/doctor.yaml:1-9`, `src/core/resources.ts:39-43`, `src/core/resources.ts:79-103`, `src/mcp/resources/doctor.ts:19`, `src/mcp/resources/doctor.ts:64-71` +**Conclusion:** Confirmed. + +### 2026-02-28 / Phase 3 - Logging and telemetry effects +**Hypothesis:** DEBUG also affects logging and telemetry context. +**Findings:** +- CLI passes `logLevel: 'info'` to daemon-backed bridge discovery when `config.debug` is true. +- That maps to env override `XCODEBUILDMCP_DAEMON_LOG_LEVEL`. +- MCP server log level defaults to `info` regardless of debug. +- MCP + daemon include `debugEnabled` in Sentry runtime context; Sentry stores it as tag `config.debug_enabled`. +**Evidence:** `src/cli.ts:136`, `src/cli/cli-tool-catalog.ts:57`, `src/server/start-mcp-server.ts:39`, `src/server/start-mcp-server.ts:67`, `src/daemon.ts:155`, `src/daemon.ts:211`, `src/utils/sentry.ts:219` +**Conclusion:** Confirmed (logging effect is scoped; telemetry effect is tagging only). + +### 2026-02-28 / Phase 4 - Compile-time vs runtime mechanism +**Hypothesis:** There may be a compile-time DEBUG constant. +**Findings:** No `process.env.DEBUG`, no `debug` package usage, and no tsup `define` replacement for DEBUG; `debug` is runtime config plumbing. +**Evidence:** `tsup.config.ts:1-61`, `package.json:1-108`, repository search results for `process.env.DEBUG` and `from 'debug'` returned no matches. +**Conclusion:** Compile-time hypothesis eliminated. + +### 2026-02-28 / Phase 4 - Historical drift and docs mismatch +**Hypothesis:** Some docs are stale relative to current predicate-based system. +**Findings:** +- `TOOL_DISCOVERY_LOGIC.md` still references `shouldExposeTool` and `src/utils/tool-visibility.ts` (not present in current src search). +- Current code uses predicate registry/exposure pipeline. +- Other docs phrase DEBUG as logging-only, which is incomplete (it also changes visibility and telemetry tags). +**Evidence:** `docs/dev/TOOL_DISCOVERY_LOGIC.md:47,75,105,116`, src search for `shouldExposeTool` returned no runtime matches, `docs/CONFIGURATION.md:191`, `server.json:52`, `docs/dev/CONTRIBUTING.md:223`, `src/visibility/predicate-registry.ts:16`, `src/visibility/exposure.ts:39-64` +**Conclusion:** Confirmed doc/code drift in at least one dev doc and minor wording incompleteness in public metadata/docs. + +## Root Cause +`debug` is currently a **visibility/diagnostic feature flag** implemented via manifest predicates and runtime config layering. Confusion stems from mixed documentation language (often “debug logging”) while code uses `debug` for broader concerns: auto-including debug-gated workflows/tools and tagging runtime telemetry context. + +## Eliminated Hypotheses +- **Compile-time DEBUG constant:** Eliminated (no bundler define/substitution path found). +- **Global behavior switch affecting core tool execution semantics:** Not supported by evidence; effects are primarily registration/visibility + scoped logging override + telemetry tag. + +## Recommendations +1. Update docs to explicitly state that `debug` affects **tool/workflow exposure** in addition to diagnostics/logging wording. +2. Clarify in docs the distinction between: + - debug-gated `doctor` **tool** + - always-registered `xcodebuildmcp://doctor` **resource** +3. Update `docs/dev/TOOL_DISCOVERY_LOGIC.md` to current predicate-based architecture (remove stale `shouldExposeTool` references). +4. If desired product behavior is logging-only, decouple visibility gating from `debug` into a separately named config flag. + +## Preventive Measures +- Add/maintain a single “DEBUG semantics” section in `docs/CONFIGURATION.md` and link it from `server.json` description text. +- Add a doc consistency test or lint check for known-removed APIs/paths (`shouldExposeTool`, `tool-visibility.ts`). +- Keep manifest predicate changes paired with docs updates in the same PR checklist. diff --git a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml index 72b0293e..c0a9f17c 100644 --- a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml +++ b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml @@ -4,6 +4,8 @@ enabledWorkflows: - simulator - ui-automation - xcode-ide +debug: false +sentryDisabled: false sessionDefaults: workspacePath: CalculatorApp.xcworkspace scheme: CalculatorApp @@ -17,5 +19,3 @@ sessionDefaults: derivedDataPath: ./iOS_Calculator/.derivedData preferXcodebuild: true bundleId: io.sentry.calculatorapp -debug: false -sentryDisabled: false