Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [Unreleased]

### Added

- Added platform selection step to the `xcodebuildmcp setup` wizard. You now choose which platforms you are developing for (macOS, iOS, tvOS, watchOS, visionOS) before selecting workflows. Based on the selection, the wizard automatically recommends the appropriate workflow set.

### Changed

- The `setup` wizard no longer prompts for a simulator or device when macOS is the only selected platform — macOS apps run natively and do not require a simulator or physical device.
- When a single platform is selected, `xcodebuildmcp setup` now writes `platform` to `sessionDefaults` in `config.yaml` and includes `XCODEBUILDMCP_PLATFORM` in `--format mcp-json` output. For multi-platform projects the platform key is omitted so the agent can choose per-command.
- The `setup` wizard remembers previous choices on re-run: existing `config.yaml` values (including the new `platform`) are pre-loaded as defaults for every prompt.

## [2.3.0]

### Added
Expand Down
246 changes: 246 additions & 0 deletions src/cli/commands/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ function createTestPrompter(): Prompter {
};
}

function createPlatformPrompter(platforms: string[]): Prompter {
let selectManyCalls = 0;
return {
selectOne: async <T>(opts: { options: Array<{ value: T }> }) => {
const preferredOption = opts.options.find((option) => option.value != null);
return (preferredOption ?? opts.options[0]).value;
},
selectMany: async <T>(opts: { options: Array<{ value: T }> }) => {
selectManyCalls++;
if (selectManyCalls === 1) {
return opts.options
.filter((option) => platforms.includes(String(option.value)))
.map((option) => option.value);
}
return opts.options.map((option) => option.value);
},
confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue,
};
}

describe('setup command', () => {
const originalStdinIsTTY = process.stdin.isTTY;
const originalStdoutIsTTY = process.stdout.isTTY;
Expand Down Expand Up @@ -1054,4 +1074,230 @@ sessionDefaults:

await expect(runSetupWizard()).rejects.toThrow('requires an interactive TTY');
});

it('skips simulator and sets platform for macOS-only selection', async () => {
let storedConfig = '';

const fs = createMockFileSystemExecutor({
existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0,
stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }),
readdir: async (targetPath) => {
if (targetPath === cwd) {
return [
{
name: 'App.xcworkspace',
isDirectory: () => true,
isSymbolicLink: () => false,
},
];
}
return [];
},
readFile: async (targetPath) => {
if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`);
return storedConfig;
},
writeFile: async (targetPath, content) => {
if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`);
storedConfig = content;
},
});

const executor: CommandExecutor = async () =>
createMockCommandResponse({
success: true,
output: `Information about workspace "App":\n Schemes:\n App`,
});

await runSetupWizard({
cwd,
fs,
executor,
prompter: createPlatformPrompter(['macOS']),
quietOutput: true,
});

const parsed = parseYaml(storedConfig) as {
sessionDefaults?: Record<string, unknown>;
};

expect(parsed.sessionDefaults?.platform).toBe('macOS');
expect(parsed.sessionDefaults?.simulatorId).toBeUndefined();
expect(parsed.sessionDefaults?.simulatorName).toBeUndefined();
});

it('outputs XCODEBUILDMCP_PLATFORM=macOS and no simulator fields for macOS-only mcp-json', async () => {
const fs = createMockFileSystemExecutor({
existsSync: () => false,
stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }),
readdir: async (targetPath) => {
if (targetPath === cwd) {
return [
{
name: 'App.xcworkspace',
isDirectory: () => true,
isSymbolicLink: () => false,
},
];
}
return [];
},
readFile: async () => '',
writeFile: async () => {},
});

const executor: CommandExecutor = async () =>
createMockCommandResponse({
success: true,
output: `Information about workspace "App":\n Schemes:\n App`,
});

const result = await runSetupWizard({
cwd,
fs,
executor,
prompter: createPlatformPrompter(['macOS']),
quietOutput: true,
outputFormat: 'mcp-json',
});

expect(result.mcpConfigJson).toBeDefined();
const parsed = JSON.parse(result.mcpConfigJson!) as {
mcpServers: { XcodeBuildMCP: { env: Record<string, string> } };
};
const env = parsed.mcpServers.XcodeBuildMCP.env;

expect(env.XCODEBUILDMCP_PLATFORM).toBe('macOS');
expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBeUndefined();
expect(env.XCODEBUILDMCP_SIMULATOR_NAME).toBeUndefined();
});

it('outputs XCODEBUILDMCP_PLATFORM=iOS Simulator and simulator fields for iOS-only mcp-json', async () => {
const fs = createMockFileSystemExecutor({
existsSync: () => false,
stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }),
readdir: async (targetPath) => {
if (targetPath === cwd) {
return [
{
name: 'App.xcworkspace',
isDirectory: () => true,
isSymbolicLink: () => false,
},
];
}
return [];
},
readFile: async () => '',
writeFile: async () => {},
});

const executor: CommandExecutor = async (command) => {
if (command.includes('--json')) {
return createMockCommandResponse({
success: true,
output: JSON.stringify({
devices: {
'iOS 17.0': [
{ name: 'iPhone 15', udid: 'SIM-1', state: 'Shutdown', isAvailable: true },
],
},
}),
});
}
if (command[0] === 'xcrun') {
return createMockCommandResponse({
success: true,
output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-1) (Shutdown)`,
});
}
return createMockCommandResponse({
success: true,
output: `Information about workspace "App":\n Schemes:\n App`,
});
};

const result = await runSetupWizard({
cwd,
fs,
executor,
prompter: createPlatformPrompter(['iOS']),
quietOutput: true,
outputFormat: 'mcp-json',
});

expect(result.mcpConfigJson).toBeDefined();
const parsed = JSON.parse(result.mcpConfigJson!) as {
mcpServers: { XcodeBuildMCP: { env: Record<string, string> } };
};
const env = parsed.mcpServers.XcodeBuildMCP.env;

expect(env.XCODEBUILDMCP_PLATFORM).toBe('iOS Simulator');
expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1');
expect(env.XCODEBUILDMCP_SIMULATOR_NAME).toBe('iPhone 15');
});

it('omits XCODEBUILDMCP_PLATFORM for multi-platform mcp-json', async () => {
const fs = createMockFileSystemExecutor({
existsSync: () => false,
stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }),
readdir: async (targetPath) => {
if (targetPath === cwd) {
return [
{
name: 'App.xcworkspace',
isDirectory: () => true,
isSymbolicLink: () => false,
},
];
}
return [];
},
readFile: async () => '',
writeFile: async () => {},
});

const executor: CommandExecutor = async (command) => {
if (command.includes('--json')) {
return createMockCommandResponse({
success: true,
output: JSON.stringify({
devices: {
'iOS 17.0': [
{ name: 'iPhone 15', udid: 'SIM-1', state: 'Shutdown', isAvailable: true },
],
},
}),
});
}
if (command[0] === 'xcrun') {
return createMockCommandResponse({
success: true,
output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-1) (Shutdown)`,
});
}
return createMockCommandResponse({
success: true,
output: `Information about workspace "App":\n Schemes:\n App`,
});
};

const result = await runSetupWizard({
cwd,
fs,
executor,
prompter: createPlatformPrompter(['macOS', 'iOS']),
quietOutput: true,
outputFormat: 'mcp-json',
});

expect(result.mcpConfigJson).toBeDefined();
const parsed = JSON.parse(result.mcpConfigJson!) as {
mcpServers: { XcodeBuildMCP: { env: Record<string, string> } };
};
const env = parsed.mcpServers.XcodeBuildMCP.env;

expect(env.XCODEBUILDMCP_PLATFORM).toBeUndefined();
expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1');
});
});
Loading