From d2450ca995f27f014cadeed56ab6cabeac46dbcb Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 27 Feb 2026 22:12:40 +0000 Subject: [PATCH 01/14] 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 + .../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 | 61 +- src/cli/commands/__tests__/setup.test.ts | 164 +++++ src/cli/commands/init.ts | 281 +++++++-- 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 + 31 files changed, 1713 insertions(+), 224 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 ca45339a..0e37a783 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 93922018..6025309c 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/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 c1b3ae4f..cfdc272a 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", @@ -5326,7 +5348,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": { @@ -6083,6 +6104,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 d630f016..046b8716 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, { workspaceRoot: process.cwd() }); 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 6f7f82ee..a744a840 100644 --- a/src/cli/commands/__tests__/init.test.ts +++ b/src/cli/commands/__tests__/init.test.ts @@ -131,16 +131,36 @@ 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(); + }); + it('skips Claude for MCP skill in auto-detect mode', async () => { const fakeHome = join(tempDir, 'home-auto-skip-claude'); mkdirSync(join(fakeHome, '.claude'), { recursive: true }); - mkdirSync(join(fakeHome, '.cursor'), { recursive: true }); + mkdirSync(join(fakeHome, '.agents'), { recursive: true }); mockedHomedir.mockReturnValue(fakeHome); const yargs = (await import('yargs')).default; const mod = await loadInitModule(); - const app = yargs(['init', '--skill', 'mcp']).scriptName(''); + const app = yargs(['init', '--skill', 'mcp', '--client', 'auto']).scriptName(''); mod.registerInitCommand(app); const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -149,7 +169,7 @@ describe('init command', () => { expect(existsSync(join(fakeHome, '.claude', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe( false, ); - expect(existsSync(join(fakeHome, '.cursor', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe( + expect(existsSync(join(fakeHome, '.agents', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe( true, ); @@ -247,7 +267,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 }); }); @@ -393,8 +413,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(); }); @@ -572,7 +591,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); @@ -580,7 +625,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 2dab509c..e5dca2e9 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,8 +16,7 @@ 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' }, ]; const AGENTS_FILE_NAME = 'AGENTS.md'; @@ -72,22 +72,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 { @@ -124,15 +144,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.'); @@ -142,11 +162,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.'); } @@ -184,7 +204,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.', @@ -196,7 +216,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) }]; @@ -259,7 +279,7 @@ async function ensureAgentsGuidance( ); } - const confirmed = await promptYesNo(`Update ${AGENTS_FILE_NAME} with the guidance above?`); + const confirmed = await promptConfirm(`Update ${AGENTS_FILE_NAME} with the guidance above?`); if (!confirmed) { writeLine(`Skipped updating ${AGENTS_FILE_NAME}.`); return 'skipped'; @@ -275,6 +295,143 @@ async function ensureAgentsGuidance( return 'updated'; } +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, ctx?: { workspaceRoot: string }): void { app.command( 'init', @@ -283,15 +440,13 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): 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', @@ -319,43 +474,74 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): }); }, async (argv) => { - const skillType = argv.skill as SkillType; - const clientFlag = argv.client as string | undefined; - const destFlag = argv.dest as string | undefined; - if (argv.print) { - const content = readSkillContent(skillType); + const content = readSkillContent((argv.skill as SkillType | undefined) ?? 'cli'); process.stdout.write(content); return; } + const isTTY = isInteractiveTTY(); + const clientFlag = argv.client as string | undefined; + const destFlag = argv.dest as string | undefined; + if (argv.uninstall) { - const targets = resolveTargets(clientFlag, destFlag, 'uninstall'); + if (isTTY) { + clack.intro('XcodeBuildMCP Init'); + clack.log.info('Removing XcodeBuildMCP agent skills from detected AI clients.'); + } + + const targets = resolveTargets(clientFlag ?? 'auto', destFlag, 'uninstall'); let anyRemoved = false; for (const target of targets) { 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(clientFlag, destFlag, '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 policy = enforceInstallPolicy(targets, skillType, clientFlag, destFlag); + const policy = enforceInstallPolicy( + selection.targets, + selection.skillType, + clientFlag, + destFlag, + ); for (const skipped of policy.skippedClients) { writeLine(`Skipped ${skipped.client}: ${skipped.reason}`); } @@ -368,17 +554,20 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): const results: InstallResult[] = []; for (const target of policy.allowedTargets) { - const result = await installSkill(target.skillsDir, target.name, skillType, { + 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.'); } if (ctx?.workspaceRoot) { 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 0d87012e..4c8cc5db 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, { workspaceRoot: opts.workspaceRoot }); + 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 f849e0da..84a0b9be 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 b7ae4de0..a7ac6847 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 0f208cc6..5fd5d5a8 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 c5b630f3..52e0ce6e 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 36c89fdfbc47c5fd206c78a72e3b069686ac6521 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 27 Feb 2026 22:27:37 +0000 Subject: [PATCH 02/14] 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 e5dca2e9..498648b3 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'; @@ -88,10 +88,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 6b0a64d0d54fbc8bb36a912f131abf98931530fc Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 28 Feb 2026 20:21:57 +0000 Subject: [PATCH 03/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(logger):=20?= =?UTF-8?q?rename=20'warning'=20log=20level=20to=20'warn'=20and=20add=20no?= =?UTF-8?q?rmalizeLogLevel?= 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 046b8716..3c63e2c6 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 4c8cc5db..79331865 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 850a6260..df8574a3 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 3af06aff..ee2c46bc 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 f6cdecb6..020ada0b 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 e0900368..144b60df 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 923c0bbd..e3c5ecf4 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 84eba493d26fcdab12dcb31ccbf27c110dd9bc49 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 28 Feb 2026 20:22:15 +0000 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=90=9B=20fix(setup):=20show=20debug?= =?UTF-8?q?-gated=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 18b09a4eacd4dfe68af88a0e313848c1789d6deb Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 28 Feb 2026 20:22:33 +0000 Subject: [PATCH 05/14] =?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 From 7cd1f6a2d5fc74c51e5bbafb76f90ddbd49833ad Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 11:04:04 +0000 Subject: [PATCH 06/14] fix(init): Emit JSON output in non-interactive mode Return structured JSON from init install/uninstall flows when no TTY is available. Keep clack-based output for interactive sessions. This gives agent and CI callers a stable machine-readable contract without introducing additional flags or output modes. --- src/cli/commands/__tests__/init.test.ts | 76 +++++++++++------ src/cli/commands/init.ts | 108 +++++++++++++++++------- 2 files changed, 129 insertions(+), 55 deletions(-) diff --git a/src/cli/commands/__tests__/init.test.ts b/src/cli/commands/__tests__/init.test.ts index a744a840..2adfbfee 100644 --- a/src/cli/commands/__tests__/init.test.ts +++ b/src/cli/commands/__tests__/init.test.ts @@ -29,6 +29,11 @@ function loadInitModule() { return import('../init.ts'); } +function parseJsonOutput(stdoutSpy: { mock: { calls: unknown[][] } }): Record { + const output = stdoutSpy.mock.calls.map((call) => String(call[0] ?? '')).join(''); + return JSON.parse(output.trim()) as Record; +} + describe('init command', () => { let tempDir: string; let fakeResourceRoot: string; @@ -81,10 +86,11 @@ describe('init command', () => { expect(existsSync(installed)).toBe(true); expect(readFileSync(installed, 'utf8')).toBe('# CLI Skill Content'); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Installed XcodeBuildMCP CLI skill'); - expect(output).toContain('Custom'); - expect(output).toContain(installed); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('install'); + expect(output.skillType).toBe('cli'); + expect(output.message).toBe('Installed XcodeBuildMCP CLI skill'); + expect(output.installed).toEqual([{ client: 'Custom', location: installed }]); stdoutSpy.mockRestore(); }); @@ -106,8 +112,10 @@ describe('init command', () => { expect(existsSync(installed)).toBe(true); expect(readFileSync(installed, 'utf8')).toBe('# MCP Skill Content'); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Installed XcodeBuildMCP (MCP server) skill'); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('install'); + expect(output.skillType).toBe('mcp'); + expect(output.message).toBe('Installed XcodeBuildMCP (MCP server) skill'); stdoutSpy.mockRestore(); }); @@ -173,8 +181,15 @@ describe('init command', () => { true, ); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Skipped Claude Code'); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('install'); + expect(output.skillType).toBe('mcp'); + expect(output.skipped).toEqual([ + { + client: 'Claude Code', + reason: 'MCP skill is unnecessary because Claude Code already uses server instructions.', + }, + ]); stdoutSpy.mockRestore(); }); @@ -317,10 +332,16 @@ describe('init command', () => { expect(existsSync(cliSkillDir)).toBe(false); expect(existsSync(mcpSkillDir)).toBe(false); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Uninstalled skill directories'); - expect(output).toContain('Removed (xcodebuildmcp-cli):'); - expect(output).toContain('Removed (xcodebuildmcp):'); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('uninstall'); + expect(output.message).toBe('Uninstalled skill directories'); + expect(output.removed).toHaveLength(2); + expect(output.removed).toEqual( + expect.arrayContaining([ + { client: 'Custom', variant: 'xcodebuildmcp-cli', path: cliSkillDir }, + { client: 'Custom', variant: 'xcodebuildmcp', path: mcpSkillDir }, + ]), + ); stdoutSpy.mockRestore(); }); @@ -338,8 +359,10 @@ describe('init command', () => { const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await app.parseAsync(); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('No installed skill directories found'); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('uninstall'); + expect(output.message).toBe('No installed skill directories found to remove.'); + expect(output.removed).toEqual([]); stdoutSpy.mockRestore(); }); @@ -358,8 +381,10 @@ describe('init command', () => { const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await app.parseAsync(); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('No installed skill directories found'); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('uninstall'); + expect(output.message).toBe('No installed skill directories found to remove.'); + expect(output.removed).toEqual([]); stdoutSpy.mockRestore(); }); @@ -439,8 +464,9 @@ describe('init command', () => { expect(existsSync(agentsPath)).toBe(true); expect(readFileSync(agentsPath, 'utf8')).toContain(agentsGuidanceLine); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Created AGENTS.md with XcodeBuildMCP guidance'); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('install'); + expect(output.message).toBe('Installed XcodeBuildMCP CLI skill'); stdoutSpy.mockRestore(); }); @@ -466,11 +492,9 @@ describe('init command', () => { 'AGENTS.md exists and requires confirmation to update', ); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Proposed update for'); - expect(output).toContain('--- AGENTS.md'); - expect(output).toContain('+++ AGENTS.md'); - expect(output).toContain(agentsGuidanceLine); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('install'); + expect(output.message).toBe('Installed XcodeBuildMCP CLI skill'); stdoutSpy.mockRestore(); Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); @@ -499,9 +523,9 @@ describe('init command', () => { expect(agentsContent).toContain('# Existing'); expect(agentsContent).toContain(agentsGuidanceLine); - const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Proposed update for'); - expect(output).toContain('Updated AGENTS.md at'); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('install'); + expect(output.message).toBe('Installed XcodeBuildMCP CLI skill'); stdoutSpy.mockRestore(); Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 498648b3..29ac820b 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -124,6 +124,15 @@ function formatSkippedClients(skippedClients: Array<{ client: string; reason: st return skippedClients.map((skipped) => `${skipped.client}: ${skipped.reason}`).join('; '); } +interface InitReport { + action: 'install' | 'uninstall'; + skillType?: SkillType; + installed?: InstallResult[]; + removed?: Array<{ client: string; variant: string; path: string }>; + skipped?: Array<{ client: string; reason: string }>; + message: string; +} + async function installSkill( skillsDir: string, clientName: string, @@ -239,18 +248,23 @@ function renderAgentsAppendDiff(fileName: string): string { async function ensureAgentsGuidance( projectRoot: string, force: boolean, + emitOutput: boolean, ): Promise<'created' | 'updated' | 'no_change' | 'skipped'> { const agentsPath = path.join(projectRoot, AGENTS_FILE_NAME); if (!fs.existsSync(agentsPath)) { const newContent = `# ${AGENTS_FILE_NAME}\n\n${AGENTS_GUIDANCE_LINE}\n`; fs.writeFileSync(agentsPath, newContent, 'utf8'); - writeLine(`Created ${AGENTS_FILE_NAME} with XcodeBuildMCP guidance at ${agentsPath}`); + if (emitOutput) { + writeLine(`Created ${AGENTS_FILE_NAME} with XcodeBuildMCP guidance at ${agentsPath}`); + } return 'created'; } const currentContent = fs.readFileSync(agentsPath, 'utf8'); if (currentContent.includes(AGENTS_GUIDANCE_LINE)) { - writeLine(`${AGENTS_FILE_NAME} already includes XcodeBuildMCP guidance.`); + if (emitOutput) { + writeLine(`${AGENTS_FILE_NAME} already includes XcodeBuildMCP guidance.`); + } return 'no_change'; } @@ -260,13 +274,17 @@ async function ensureAgentsGuidance( AGENTS_GUIDANCE_LINE, ); fs.writeFileSync(agentsPath, updatedFromLegacy, 'utf8'); - writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`); + if (emitOutput) { + writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`); + } return 'updated'; } const diff = renderAgentsAppendDiff(AGENTS_FILE_NAME); - writeLine(`Proposed update for ${agentsPath}:`); - writeLine(diff); + if (emitOutput) { + writeLine(`Proposed update for ${agentsPath}:`); + writeLine(diff); + } if (!force) { if (!process.stdin.isTTY) { @@ -277,7 +295,9 @@ async function ensureAgentsGuidance( const confirmed = await promptConfirm(`Update ${AGENTS_FILE_NAME} with the guidance above?`); if (!confirmed) { - writeLine(`Skipped updating ${AGENTS_FILE_NAME}.`); + if (emitOutput) { + writeLine(`Skipped updating ${AGENTS_FILE_NAME}.`); + } return 'skipped'; } } @@ -287,7 +307,9 @@ async function ensureAgentsGuidance( : `${currentContent}\n${AGENTS_GUIDANCE_LINE}\n`; fs.writeFileSync(agentsPath, updatedContent, 'utf8'); - writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`); + if (emitOutput) { + writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`); + } return 'updated'; } @@ -487,29 +509,48 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): } const targets = resolveTargets(clientFlag ?? 'auto', destFlag, 'uninstall'); - let anyRemoved = false; + const removedEntries: Array<{ client: string; variant: string; path: string }> = []; for (const target of targets) { const result = uninstallSkill(target.skillsDir, target.name); - if (result) { - if (!anyRemoved) { - 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 (!result) { + continue; } - } - if (!anyRemoved) { - clack.log.info('No installed skill directories found to remove.'); + for (const removed of result.removed) { + removedEntries.push({ + client: result.client, + variant: removed.variant, + path: removed.path, + }); + } } + const report: InitReport = { + action: 'uninstall', + removed: removedEntries, + message: + removedEntries.length > 0 + ? 'Uninstalled skill directories' + : 'No installed skill directories found to remove.', + }; + if (isTTY) { - clack.outro(anyRemoved ? 'Done.' : undefined); + if (removedEntries.length > 0) { + clack.log.step(report.message); + for (const removed of removedEntries) { + clack.log.message( + ` Client: ${removed.client}\n Removed (${removed.variant}): ${removed.path}`, + ); + } + } else { + clack.log.info(report.message); + } + clack.outro('Done.'); + } else { + process.stdout.write(`${JSON.stringify(report)}\n`); } + return; } @@ -538,9 +579,6 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): clientFlag, destFlag, ); - for (const skipped of policy.skippedClients) { - writeLine(`Skipped ${skipped.client}: ${skipped.reason}`); - } if (policy.allowedTargets.length === 0) { const skippedSummary = formatSkippedClients(policy.skippedClients); @@ -557,18 +595,30 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): results.push(result); } - clack.log.success(`Installed ${skillDisplayName(selection.skillType)} skill`); - for (const result of results) { - clack.log.message(` Client: ${result.client}\n Location: ${result.location}`); - } + const report: InitReport = { + action: 'install', + skillType: selection.skillType, + installed: results, + skipped: policy.skippedClients, + message: `Installed ${skillDisplayName(selection.skillType)} skill`, + }; if (isTTY) { + for (const skipped of report.skipped ?? []) { + clack.log.info(`Skipped ${skipped.client}: ${skipped.reason}`); + } + clack.log.success(report.message); + for (const result of results) { + clack.log.message(` Client: ${result.client}\n Location: ${result.location}`); + } clack.outro('Done.'); + } else { + process.stdout.write(`${JSON.stringify(report)}\n`); } if (ctx?.workspaceRoot) { const projectRoot = path.resolve(ctx.workspaceRoot); - await ensureAgentsGuidance(projectRoot, argv.force as boolean); + await ensureAgentsGuidance(projectRoot, argv.force as boolean, isTTY); } }, ); From 23240a267fe6d442ca6b57478b818c17df64f54b Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 12:30:30 +0000 Subject: [PATCH 07/14] fix(init): Include AGENTS guidance in non-interactive JSON Capture AGENTS guidance outcomes during non-interactive init and embed the status in the emitted JSON report. Include path and error details when AGENTS updates fail so automation can reason about partial success without parsing stderr. Preserve existing failure behavior by throwing after JSON emission on AGENTS errors, and extend init command tests to cover created, updated, and error statuses in JSON output. --- src/cli/commands/__tests__/init.test.ts | 22 +++++++++++++ src/cli/commands/init.ts | 42 +++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/__tests__/init.test.ts b/src/cli/commands/__tests__/init.test.ts index 2adfbfee..13fdceac 100644 --- a/src/cli/commands/__tests__/init.test.ts +++ b/src/cli/commands/__tests__/init.test.ts @@ -467,6 +467,10 @@ describe('init command', () => { const output = parseJsonOutput(stdoutSpy); expect(output.action).toBe('install'); expect(output.message).toBe('Installed XcodeBuildMCP CLI skill'); + expect(output.agentsGuidance).toEqual({ + status: 'created', + path: agentsPath, + }); stdoutSpy.mockRestore(); }); @@ -495,6 +499,12 @@ describe('init command', () => { const output = parseJsonOutput(stdoutSpy); expect(output.action).toBe('install'); expect(output.message).toBe('Installed XcodeBuildMCP CLI skill'); + expect(output.agentsGuidance).toEqual({ + status: 'error', + path: join(projectRoot, 'AGENTS.md'), + error: + 'AGENTS.md exists and requires confirmation to update. Re-run with --force to apply the change in non-interactive mode.', + }); stdoutSpy.mockRestore(); Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); @@ -526,6 +536,10 @@ describe('init command', () => { const output = parseJsonOutput(stdoutSpy); expect(output.action).toBe('install'); expect(output.message).toBe('Installed XcodeBuildMCP CLI skill'); + expect(output.agentsGuidance).toEqual({ + status: 'updated', + path: join(projectRoot, 'AGENTS.md'), + }); stdoutSpy.mockRestore(); Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); @@ -560,6 +574,14 @@ describe('init command', () => { )?.length, ).toBe(1); + const output = parseJsonOutput(stdoutSpy); + expect(output.action).toBe('install'); + expect(output.message).toBe('Installed XcodeBuildMCP CLI skill'); + expect(output.agentsGuidance).toEqual({ + status: 'updated', + path: join(projectRoot, 'AGENTS.md'), + }); + stdoutSpy.mockRestore(); }); }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 29ac820b..d2863372 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -124,12 +124,19 @@ function formatSkippedClients(skippedClients: Array<{ client: string; reason: st return skippedClients.map((skipped) => `${skipped.client}: ${skipped.reason}`).join('; '); } +type AgentsGuidanceStatus = 'created' | 'updated' | 'no_change' | 'skipped' | 'error'; + interface InitReport { action: 'install' | 'uninstall'; skillType?: SkillType; installed?: InstallResult[]; removed?: Array<{ client: string; variant: string; path: string }>; skipped?: Array<{ client: string; reason: string }>; + agentsGuidance?: { + status: AgentsGuidanceStatus; + path: string; + error?: string; + }; message: string; } @@ -595,11 +602,38 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): results.push(result); } + let agentsGuidanceStatus: AgentsGuidanceStatus | undefined; + let agentsGuidancePath: string | undefined; + let agentsGuidanceError: string | undefined; + if (!isTTY && ctx?.workspaceRoot) { + const projectRoot = path.resolve(ctx.workspaceRoot); + agentsGuidancePath = path.join(projectRoot, AGENTS_FILE_NAME); + try { + agentsGuidanceStatus = await ensureAgentsGuidance( + projectRoot, + argv.force as boolean, + false, + ); + } catch (error) { + agentsGuidanceStatus = 'error'; + agentsGuidanceError = error instanceof Error ? error.message : String(error); + } + } + const report: InitReport = { action: 'install', skillType: selection.skillType, installed: results, skipped: policy.skippedClients, + ...(agentsGuidanceStatus && agentsGuidancePath + ? { + agentsGuidance: { + status: agentsGuidanceStatus, + path: agentsGuidancePath, + ...(agentsGuidanceError ? { error: agentsGuidanceError } : {}), + }, + } + : {}), message: `Installed ${skillDisplayName(selection.skillType)} skill`, }; @@ -616,9 +650,13 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): process.stdout.write(`${JSON.stringify(report)}\n`); } - if (ctx?.workspaceRoot) { + if (agentsGuidanceStatus === 'error' && agentsGuidanceError) { + throw new Error(agentsGuidanceError); + } + + if (ctx?.workspaceRoot && isTTY) { const projectRoot = path.resolve(ctx.workspaceRoot); - await ensureAgentsGuidance(projectRoot, argv.force as boolean, isTTY); + await ensureAgentsGuidance(projectRoot, argv.force as boolean, true); } }, ); From 31c5572eb0d0478cb7e76afabc3c6e0f54b08462 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 12:50:01 +0000 Subject: [PATCH 08/14] fix: address init policy and argv filtering review feedback --- src/cli/commands/init.ts | 14 +++++++++++--- src/cli/register-tool-commands.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index d2863372..202cd499 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -294,7 +294,7 @@ async function ensureAgentsGuidance( } if (!force) { - if (!process.stdin.isTTY) { + if (!isInteractiveTTY()) { throw new Error( `${AGENTS_FILE_NAME} exists and requires confirmation to update. Re-run with --force to apply the change in non-interactive mode.`, ); @@ -325,6 +325,7 @@ const CUSTOM_PATH_SENTINEL = '__custom__'; interface InitSelection { skillType: SkillType; targets: ClientInfo[]; + selectionMode: 'flags_or_dest' | 'interactive'; } async function collectInitSelection( @@ -369,12 +370,13 @@ async function collectInitSelection( return { skillType, targets: [{ name: 'Custom', id: 'custom', skillsDir: resolvedDest }], + selectionMode: 'flags_or_dest', }; } if (argv.client !== undefined) { const targets = resolveTargets(argv.client, undefined, 'install'); - return { skillType, targets }; + return { skillType, targets, selectionMode: 'flags_or_dest' }; } if (!interactive) { @@ -429,7 +431,7 @@ async function collectInitSelection( } } - return { skillType, targets }; + return { skillType, targets, selectionMode: 'interactive' }; } async function promptCustomPath(): Promise { @@ -585,6 +587,7 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): selection.skillType, clientFlag, destFlag, + selection.selectionMode, ); if (policy.allowedTargets.length === 0) { @@ -667,6 +670,7 @@ function enforceInstallPolicy( skillType: SkillType, clientFlag: string | undefined, destFlag: string | undefined, + selectionMode: InitSelection['selectionMode'], ): InstallPolicyResult { if (skillType !== 'mcp') { return { allowedTargets: targets, skippedClients: [] }; @@ -694,5 +698,9 @@ function enforceInstallPolicy( allowedTargets.push(target); } + if (clientFlag === 'claude' || selectionMode === 'interactive') { + return { allowedTargets: targets, skippedClients: [] }; + } + return { allowedTargets, skippedClients }; } diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index f7fbe762..f7d0ce06 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -162,7 +162,16 @@ 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', 'log-level', '_', '$0']); + const internalKeys = new Set([ + 'json', + 'output', + 'style', + 'socket', + 'log-level', + 'logLevel', + '_', + '$0', + ]); const flagArgs: Record = {}; for (const [key, value] of Object.entries(argv as Record)) { if (!internalKeys.has(key)) { From 0222b9d1d34f08a8e84b309670a848ad97c3e5ce Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 13:27:19 +0000 Subject: [PATCH 09/14] fix: respect runtime sentry disable and simplify init policy flow --- src/cli/commands/init.ts | 6 +----- src/utils/logger.ts | 8 +++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 202cd499..57946160 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -680,7 +680,7 @@ function enforceInstallPolicy( return { allowedTargets: targets, skippedClients: [] }; } - if (clientFlag === 'claude') { + if (clientFlag === 'claude' || selectionMode === 'interactive') { return { allowedTargets: targets, skippedClients: [] }; } @@ -698,9 +698,5 @@ function enforceInstallPolicy( allowedTargets.push(target); } - if (clientFlag === 'claude' || selectionMode === 'interactive') { - return { allowedTargets: targets, skippedClients: [] }; - } - return { allowedTargets, skippedClients }; } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index dc892592..628a7b56 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -27,7 +27,9 @@ function isSentryDisabledFromEnv(): boolean { ); } -const sentryEnabled = !isSentryDisabledFromEnv(); +function isSentryEnabled(): boolean { + return !isSentryDisabledFromEnv(); +} // Log levels in order of severity (lower number = more severe) const LOG_LEVELS = { @@ -78,7 +80,7 @@ const require = createRequire( let cachedSentry: SentryModule | null = null; function loadSentrySync(): SentryModule | null { - if (!sentryEnabled || isTestEnv()) { + if (!isSentryEnabled() || isTestEnv()) { return null; } if (cachedSentry) { @@ -237,7 +239,7 @@ export function log(level: string, message: string, context?: LogContext): void const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - const captureToSentry = sentryEnabled && __shouldCaptureToSentryForTests(context); + const captureToSentry = isSentryEnabled() && __shouldCaptureToSentryForTests(context); if (captureToSentry) { withSentry((s) => { From eb6a3c46643d3e332a1d57ec3a4c4177fc40d2fd Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 13:38:17 +0000 Subject: [PATCH 10/14] fix(cli): Accept warning as hidden log-level alias Preserve backwards compatibility for existing scripts that pass --log-level warning while keeping help output focused on the internal warn level token. Map warning to warn during argument coercion in both the main and lightweight yargs app builders so init/setup and normal CLI execution behave consistently. --- src/cli.ts | 4 ++++ src/cli/yargs-app.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 3c63e2c6..552b5360 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,6 +49,10 @@ async function buildLightweightYargsApp(): Promise { + if (typeof value !== 'string') return value; + return value.trim().toLowerCase() === 'warning' ? 'warn' : value; + }, default: 'none', }) .option('style', { diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 79331865..440967b6 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -45,6 +45,10 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { type: 'string', describe: 'Set log verbosity level', choices: ['none', 'error', 'warn', 'info', 'debug'] as const, + coerce: (value: unknown) => { + if (typeof value !== 'string') return value; + return value.trim().toLowerCase() === 'warning' ? 'warn' : value; + }, default: 'none', }) .option('style', { From d240011f52ecf98683b52ac65bb42a853b62f1e6 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 21:16:03 +0000 Subject: [PATCH 11/14] remove ephemeral debug-flag investigation notes Delete AI-generated investigation log that was accidentally committed as project documentation. The file contained stale line-number references and phase-by-phase working notes, not permanent docs. --- .../debug-flag-investigation-2026-02-28.md | 85 ------------------- 1 file changed, 85 deletions(-) delete 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 deleted file mode 100644 index 343c993e..00000000 --- a/docs/dev/debug-flag-investigation-2026-02-28.md +++ /dev/null @@ -1,85 +0,0 @@ -# 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. From a73d3e21b0825258aa10d85d612b20ebd1cab2ad Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 21:34:29 +0000 Subject: [PATCH 12/14] fix(cli): guard non-interactive prompter against empty options Add the same empty-options check to createNonInteractivePrompter's selectOne that the TTY prompter already has, preventing an out-of-bounds access if called with an empty array. --- src/cli/interactive/prompts.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cli/interactive/prompts.ts b/src/cli/interactive/prompts.ts index a4389c26..62f615ec 100644 --- a/src/cli/interactive/prompts.ts +++ b/src/cli/interactive/prompts.ts @@ -30,6 +30,9 @@ function clampIndex(index: number, optionsLength: number): number { function createNonInteractivePrompter(): Prompter { return { async selectOne(opts: { options: SelectOption[]; initialIndex?: number }): Promise { + if (opts.options.length === 0) { + throw new Error('No options available for selection.'); + } const index = clampIndex(opts.initialIndex ?? 0, opts.options.length); return opts.options[index].value; }, From af96d9dd013272b4beea219dd8b57edc35b21871 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 21:47:22 +0000 Subject: [PATCH 13/14] fix(cli): add warning-to-warn coercer for --daemon-log-level The --log-level option already coerces 'warning' to 'warn' for backward compatibility, but --daemon-log-level was missing the same coercer, causing a yargs validation error for users passing the old value. --- src/cli/commands/daemon.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 9a71fbcb..91f386fc 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -53,6 +53,10 @@ export function registerDaemonCommands(app: Argv, opts: DaemonCommandsOptions): 'info', 'debug', ] as const, + coerce: (value: unknown) => { + if (typeof value !== 'string') return value; + return value.trim().toLowerCase() === 'warning' ? 'warn' : value; + }, }) .option('tail', { type: 'number', From 7c97e9dac16afcc47b46d63921c27b1fd14653b6 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Mar 2026 22:27:20 +0000 Subject: [PATCH 14/14] fix(init): adapt MCP auto-install test for interactive wizard flow The setup wizard requires explicit --client or --dest in non-interactive mode. Add --client auto to the test so it exercises the auto-detect policy path, and emit skip reasons before the zero-targets error. --- src/cli/commands/__tests__/init.test.ts | 2 +- src/cli/commands/init.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/__tests__/init.test.ts b/src/cli/commands/__tests__/init.test.ts index 13fdceac..eb6e7999 100644 --- a/src/cli/commands/__tests__/init.test.ts +++ b/src/cli/commands/__tests__/init.test.ts @@ -202,7 +202,7 @@ describe('init command', () => { const yargs = (await import('yargs')).default; const mod = await loadInitModule(); - const app = yargs(['init', '--skill', 'mcp']).scriptName('').fail(false); + const app = yargs(['init', '--skill', 'mcp', '--client', 'auto']).scriptName('').fail(false); mod.registerInitCommand(app); const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 57946160..129553e4 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -591,6 +591,9 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): ); if (policy.allowedTargets.length === 0) { + for (const skipped of policy.skippedClients) { + writeLine(`Skipped ${skipped.client}: ${skipped.reason}`); + } const skippedSummary = formatSkippedClients(policy.skippedClients); const reasonSuffix = skippedSummary.length > 0 ? ` Skipped: ${skippedSummary}` : ''; throw new Error(`No eligible install targets after applying skill policy.${reasonSuffix}`);