Skip to content

Commit bc478cd

Browse files
committed
feat(cli): add interactive setup wizard and modernize init command
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
1 parent 13eeb84 commit bc478cd

31 files changed

Lines changed: 1713 additions & 224 deletions

config.example.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ enabledWorkflows: ['simulator', 'ui-automation', 'debugging']
33
experimentalWorkflowDiscovery: false
44
disableSessionDefaults: false
55
incrementalBuildsEnabled: false
6+
debug: false
7+
sentryDisabled: false
68
sessionDefaults:
79
projectPath: './MyApp.xcodeproj' # xor workspacePath
810
workspacePath: './MyApp.xcworkspace' # xor projectPath

docs/CLI.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ xcodebuildmcp --help
2525

2626
# View tool help
2727
xcodebuildmcp <workflow> <tool> --help
28+
29+
# Run interactive setup for .xcodebuildmcp/config.yaml
30+
xcodebuildmcp setup
2831
```
2932

3033
## Tool Options
@@ -116,6 +119,8 @@ enabledWorkflows:
116119
117120
See [CONFIGURATION.md](CONFIGURATION.md) for the full schema.
118121
122+
To create/update config interactively, run `xcodebuildmcp setup`.
123+
119124
## Environment Variables
120125

121126
| Variable | Description |

docs/CONFIGURATION.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ Create a config file at your workspace root:
2525
<workspace-root>/.xcodebuildmcp/config.yaml
2626
```
2727

28+
Or run the interactive setup wizard:
29+
30+
```bash
31+
xcodebuildmcp setup
32+
```
33+
2834
Minimal example:
2935

3036
```yaml
@@ -61,6 +67,7 @@ incrementalBuildsEnabled: false
6167

6268
# Debugging
6369
debug: false
70+
sentryDisabled: false
6471
debuggerBackend: "dap"
6572
dapRequestTimeoutMs: 30000
6673
dapLogEvents: false
@@ -262,8 +269,13 @@ Default templates:
262269
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:
263270

264271
```yaml
265-
# Environment variable only (no config.yaml option)
266-
# XCODEBUILDMCP_SENTRY_DISABLED=true
272+
sentryDisabled: true
273+
```
274+
275+
You can also disable telemetry via environment variable:
276+
277+
```bash
278+
XCODEBUILDMCP_SENTRY_DISABLED=true
267279
```
268280

269281
See [PRIVACY.md](PRIVACY.md) for more information.
@@ -286,6 +298,7 @@ Notes:
286298
| `sessionDefaults` | object | `{}` |
287299
| `incrementalBuildsEnabled` | boolean | `false` |
288300
| `debug` | boolean | `false` |
301+
| `sentryDisabled` | boolean | `false` |
289302
| `debuggerBackend` | string | `"dap"` |
290303
| `dapRequestTimeoutMs` | number | `30000` |
291304
| `dapLogEvents` | boolean | `false` |
@@ -310,6 +323,7 @@ Environment variables are supported for backwards compatibility but the config f
310323
| `disableSessionDefaults` | `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` |
311324
| `incrementalBuildsEnabled` | `INCREMENTAL_BUILDS_ENABLED` |
312325
| `debug` | `XCODEBUILDMCP_DEBUG` |
326+
| `sentryDisabled` | `XCODEBUILDMCP_SENTRY_DISABLED` |
313327
| `debuggerBackend` | `XCODEBUILDMCP_DEBUGGER_BACKEND` |
314328
| `dapRequestTimeoutMs` | `XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS` |
315329
| `dapLogEvents` | `XCODEBUILDMCP_DAP_LOG_EVENTS` |
@@ -320,7 +334,6 @@ Environment variables are supported for backwards compatibility but the config f
320334
| `iosTemplateVersion` | `XCODEBUILD_MCP_IOS_TEMPLATE_VERSION` |
321335
| `macosTemplatePath` | `XCODEBUILDMCP_MACOS_TEMPLATE_PATH` |
322336
| `macosTemplateVersion` | `XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION` |
323-
| (no config option) | `XCODEBUILDMCP_SENTRY_DISABLED` |
324337

325338
Config file takes precedence over environment variables when both are set.
326339

docs/GETTING_STARTED.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ For deterministic session defaults and runtime configuration, add a config file
6969
<workspace-root>/.xcodebuildmcp/config.yaml
7070
```
7171

72+
Use the setup wizard to create or update this file interactively:
73+
74+
```bash
75+
xcodebuildmcp setup
76+
```
77+
7278
See [CONFIGURATION.md](CONFIGURATION.md) for the full schema and examples.
7379

7480
## Client-specific configuration

example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
schemaVersion: 1
22
enabledWorkflows:
3+
- debugging
34
- simulator
45
- ui-automation
5-
- debugging
66
- xcode-ide
77
sessionDefaults:
8-
workspacePath: ./CalculatorApp.xcworkspace
8+
workspacePath: CalculatorApp.xcworkspace
99
scheme: CalculatorApp
1010
configuration: Debug
1111
simulatorName: iPhone 17 Pro
@@ -17,3 +17,5 @@ sessionDefaults:
1717
derivedDataPath: ./iOS_Calculator/.derivedData
1818
preferXcodebuild: true
1919
bundleId: io.sentry.calculatorapp
20+
debug: false
21+
sentryDisabled: false

package-lock.json

Lines changed: 28 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"url": "https://github.com/getsentry/XcodeBuildMCP/issues"
7171
},
7272
"dependencies": {
73+
"@clack/prompts": "^1.0.1",
7374
"@modelcontextprotocol/sdk": "^1.25.1",
7475
"@sentry/cli": "^3.1.0",
7576
"@sentry/node": "^10.38.0",

scripts/check-docs-cli-commands.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ function extractCommandCandidates(content) {
125125
}
126126

127127
function findInvalidCommands(files, validPairs, validWorkflows) {
128-
const validTopLevel = new Set(['mcp', 'tools', 'daemon', 'init']);
128+
const validTopLevel = new Set(['mcp', 'tools', 'daemon', 'init', 'setup']);
129129
const validDaemonActions = new Set(['status', 'start', 'stop', 'restart', 'list']);
130130
const findings = [];
131131

src/cli.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { startMcpServer } from './server/start-mcp-server.ts';
77
import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts';
88
import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts';
99
import { setLogLevel, type LogLevel } from './utils/logger.ts';
10+
import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
1011

1112
function findTopLevelCommand(argv: string[]): string | undefined {
1213
const flagsWithValue = new Set(['--socket', '--log-level', '--style']);
@@ -31,12 +32,11 @@ function findTopLevelCommand(argv: string[]): string | undefined {
3132
return undefined;
3233
}
3334

34-
async function runInitCommand(): Promise<void> {
35+
async function buildLightweightYargsApp(): Promise<ReturnType<typeof import('yargs').default>> {
3536
const yargs = (await import('yargs')).default;
3637
const { hideBin } = await import('yargs/helpers');
37-
const { registerInitCommand } = await import('./cli/commands/init.ts');
3838

39-
const app = yargs(hideBin(process.argv))
39+
return yargs(hideBin(process.argv))
4040
.scriptName('')
4141
.strict()
4242
.help()
@@ -63,10 +63,22 @@ async function runInitCommand(): Promise<void> {
6363
setLogLevel(level);
6464
}
6565
});
66+
}
67+
68+
async function runInitCommand(): Promise<void> {
69+
const { registerInitCommand } = await import('./cli/commands/init.ts');
70+
const app = await buildLightweightYargsApp();
6671
registerInitCommand(app, { workspaceRoot: process.cwd() });
6772
await app.parseAsync();
6873
}
6974

75+
async function runSetupCommand(): Promise<void> {
76+
const { registerSetupCommand } = await import('./cli/commands/setup.ts');
77+
const app = await buildLightweightYargsApp();
78+
registerSetupCommand(app);
79+
await app.parseAsync();
80+
}
81+
7082
async function main(): Promise<void> {
7183
const cliBootstrapStartedAt = Date.now();
7284
const earlyCommand = findTopLevelCommand(process.argv.slice(2));
@@ -78,6 +90,12 @@ async function main(): Promise<void> {
7890
await runInitCommand();
7991
return;
8092
}
93+
if (earlyCommand === 'setup') {
94+
await runSetupCommand();
95+
return;
96+
}
97+
98+
await hydrateSentryDisabledEnvFromProjectConfig();
8199
initSentry({ mode: 'cli' });
82100

83101
// CLI mode uses disableSessionDefaults to show all tool parameters as flags

src/cli/commands/__tests__/init.test.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,36 @@ describe('init command', () => {
131131
stdoutSpy.mockRestore();
132132
});
133133

134+
it('expands ~ in --dest to home directory', async () => {
135+
const fakeHome = join(tempDir, 'home');
136+
mkdirSync(fakeHome, { recursive: true });
137+
mockedHomedir.mockReturnValue(fakeHome);
138+
139+
const yargs = (await import('yargs')).default;
140+
const mod = await loadInitModule();
141+
142+
const app = yargs(['init', '--dest', '~/skills', '--skill', 'cli']).scriptName('');
143+
mod.registerInitCommand(app);
144+
145+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
146+
await app.parseAsync();
147+
148+
const installed = join(fakeHome, 'skills', 'xcodebuildmcp-cli', 'SKILL.md');
149+
expect(existsSync(installed)).toBe(true);
150+
151+
stdoutSpy.mockRestore();
152+
});
153+
134154
it('skips Claude for MCP skill in auto-detect mode', async () => {
135155
const fakeHome = join(tempDir, 'home-auto-skip-claude');
136156
mkdirSync(join(fakeHome, '.claude'), { recursive: true });
137-
mkdirSync(join(fakeHome, '.cursor'), { recursive: true });
157+
mkdirSync(join(fakeHome, '.agents'), { recursive: true });
138158
mockedHomedir.mockReturnValue(fakeHome);
139159

140160
const yargs = (await import('yargs')).default;
141161
const mod = await loadInitModule();
142162

143-
const app = yargs(['init', '--skill', 'mcp']).scriptName('');
163+
const app = yargs(['init', '--skill', 'mcp', '--client', 'auto']).scriptName('');
144164
mod.registerInitCommand(app);
145165

146166
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
@@ -149,7 +169,7 @@ describe('init command', () => {
149169
expect(existsSync(join(fakeHome, '.claude', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe(
150170
false,
151171
);
152-
expect(existsSync(join(fakeHome, '.cursor', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe(
172+
expect(existsSync(join(fakeHome, '.agents', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe(
153173
true,
154174
);
155175

@@ -223,7 +243,7 @@ describe('init command', () => {
223243
const app = yargs(['init', '--dest', dest, '--skill', 'cli']).scriptName('').fail(false);
224244
mod.registerInitCommand(app);
225245

226-
await expect(app.parseAsync()).rejects.toThrow('Conflicting skill');
246+
await expect(app.parseAsync()).rejects.toThrow('conflicting mcp skill found');
227247

228248
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
229249
});
@@ -369,8 +389,7 @@ describe('init command', () => {
369389
await app.parseAsync();
370390

371391
expect(existsSync(join(emptyHome, '.claude', 'skills'))).toBe(false);
372-
expect(existsSync(join(emptyHome, '.cursor', 'skills'))).toBe(false);
373-
expect(existsSync(join(emptyHome, '.codex', 'skills', 'public'))).toBe(false);
392+
expect(existsSync(join(emptyHome, '.agents', 'skills'))).toBe(false);
374393

375394
stdoutSpy.mockRestore();
376395
});
@@ -548,15 +567,41 @@ describe('init command', () => {
548567
expect(readFileSync(join(conflictDir, 'SKILL.md'), 'utf8')).toBe('existing mcp skill');
549568
});
550569

551-
it('errors when no clients detected and no --dest or --print', async () => {
570+
it('errors in non-interactive mode without --client or --dest', async () => {
571+
const originalStdinIsTTY = process.stdin.isTTY;
572+
const originalStdoutIsTTY = process.stdout.isTTY;
573+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
574+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
575+
576+
const yargs = (await import('yargs')).default;
577+
const mod = await loadInitModule();
578+
579+
const app = yargs(['init', '--skill', 'cli']).scriptName('').fail(false);
580+
mod.registerInitCommand(app);
581+
582+
await expect(app.parseAsync()).rejects.toThrow(
583+
'Non-interactive mode requires --client or --dest for init',
584+
);
585+
586+
Object.defineProperty(process.stdin, 'isTTY', {
587+
value: originalStdinIsTTY,
588+
configurable: true,
589+
});
590+
Object.defineProperty(process.stdout, 'isTTY', {
591+
value: originalStdoutIsTTY,
592+
configurable: true,
593+
});
594+
});
595+
596+
it('errors when no clients detected with --client=auto and no --dest or --print', async () => {
552597
const emptyHome = join(tempDir, 'empty-home');
553598
mkdirSync(emptyHome, { recursive: true });
554599
mockedHomedir.mockReturnValue(emptyHome);
555600

556601
const yargs = (await import('yargs')).default;
557602
const mod = await loadInitModule();
558603

559-
const app = yargs(['init', '--skill', 'cli']).scriptName('').fail(false);
604+
const app = yargs(['init', '--skill', 'cli', '--client', 'auto']).scriptName('').fail(false);
560605
mod.registerInitCommand(app);
561606

562607
await expect(app.parseAsync()).rejects.toThrow('No supported AI clients detected');

0 commit comments

Comments
 (0)