xcode-cli 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/xcode.ts ADDED
@@ -0,0 +1,785 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createRuntime, createServerProxy, describeConnectionIssue } from 'mcporter';
4
+ import type { CallResult } from 'mcporter';
5
+ import { printResult, unwrapResult } from './xcode-output.ts';
6
+ import { copyPreviewToOutput, findPreviewPath } from './xcode-preview.ts';
7
+ import { parseTestSpecifier, type ParsedTestSpecifier } from './xcode-test.ts';
8
+ import { renderLsTree } from './xcode-tree.ts';
9
+ import type { CommonOpts, ClientContext } from './xcode-types.ts';
10
+
11
+ const SERVER_NAME = 'xcode-tools';
12
+ const DEFAULT_PORT = '48321';
13
+ const DEFAULT_URL = `http://localhost:${DEFAULT_PORT}/mcp`;
14
+
15
+ const program = new Command();
16
+ program
17
+ .name('xcode-cli')
18
+ .description('Friendly Xcode MCP CLI for browsing, editing, building, and testing projects.')
19
+ .option('--url <url>', `MCP endpoint (default: ${DEFAULT_URL})`)
20
+ .option('--tab <tabIdentifier>', 'Default tab identifier for commands that need it')
21
+ .option('-t, --timeout <ms>', 'Call timeout in milliseconds', '60000')
22
+ .option('--json', 'Output JSON (shorthand for --output json)')
23
+ .option('-o, --output <format>', 'text | json', parseOutputFormat, 'text');
24
+
25
+ program.addHelpText(
26
+ 'after',
27
+ `
28
+ Tab selection:
29
+ Commands that require tabIdentifier will use --tab (or XCODE_TAB_ID) when provided.
30
+ If neither is provided and exactly one Xcode tab is open, that tabIdentifier is auto-selected.
31
+
32
+ Examples:
33
+ # Discover tabIdentifier values
34
+ xcode-cli windows
35
+
36
+ # Build using a known Xcode tab identifier
37
+ xcode-cli --tab <tabIdentifier> build
38
+
39
+ Setup & service management:
40
+ Use xcode-cli-ctl to manage the bridge service and skills.
41
+ xcode-cli-ctl install / start / stop / restart / status / logs
42
+ xcode-cli-ctl skill install
43
+ `,
44
+ );
45
+
46
+ program
47
+ .command('tools')
48
+ .description('List all available Xcode MCP tools')
49
+ .action(async () => {
50
+ await withClient(async (ctx) => {
51
+ const tools = await ctx.proxy.listTools({ includeSchema: false });
52
+ if (ctx.output === 'json') {
53
+ console.log(JSON.stringify(tools, null, 2));
54
+ return;
55
+ }
56
+ for (const tool of tools) {
57
+ console.log(`${tool.name}${tool.description ? ` - ${tool.description}` : ''}`);
58
+ }
59
+ });
60
+ });
61
+
62
+ program
63
+ .command('windows')
64
+ .description('List Xcode windows/workspaces and tab identifiers')
65
+ .action(async () => {
66
+ await withClient(async (ctx) => {
67
+ const result = await ctx.call('XcodeListWindows');
68
+ printResult(result, ctx.output);
69
+ });
70
+ });
71
+
72
+ program
73
+ .command('status')
74
+ .description('Quick status: windows + issues for current tab')
75
+ .option('--severity <severity>', 'error | warning | remark', 'error')
76
+ .action(async (options: { severity: string }) => {
77
+ await withClient(async (ctx) => {
78
+ const windows = await ctx.call('XcodeListWindows');
79
+ const tabId = await resolveTabIdentifier(ctx, true, windows);
80
+ const issues = await ctx.call('XcodeListNavigatorIssues', {
81
+ tabIdentifier: tabId,
82
+ severity: options.severity,
83
+ });
84
+
85
+ if (ctx.output === 'json') {
86
+ console.log(
87
+ JSON.stringify(
88
+ {
89
+ tabIdentifier: tabId,
90
+ windows: unwrapResult(windows),
91
+ issues: unwrapResult(issues),
92
+ },
93
+ null,
94
+ 2,
95
+ ),
96
+ );
97
+ return;
98
+ }
99
+
100
+ console.log(`tabIdentifier: ${tabId}`);
101
+ console.log('');
102
+ console.log('Windows');
103
+ console.log('-------');
104
+ printResult(windows, 'text');
105
+ console.log('');
106
+ console.log('Issues');
107
+ console.log('------');
108
+ printResult(issues, 'text');
109
+ });
110
+ });
111
+
112
+ program
113
+ .command('issues')
114
+ .description('List issues from Xcode Issue Navigator')
115
+ .option('--glob <glob>', 'Filter issues by path glob')
116
+ .option('--pattern <regex>', 'Filter issues by message regex')
117
+ .option('--severity <severity>', 'error | warning | remark', 'error')
118
+ .action(async (options: { glob?: string; pattern?: string; severity: string }) => {
119
+ await withClient(async (ctx) => {
120
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
121
+ const result = await ctx.call('XcodeListNavigatorIssues', {
122
+ tabIdentifier,
123
+ severity: options.severity,
124
+ glob: options.glob,
125
+ pattern: options.pattern,
126
+ });
127
+ printResult(result, ctx.output);
128
+ });
129
+ });
130
+
131
+ program
132
+ .command('file-issues <filePath>')
133
+ .description('Refresh and list compiler diagnostics for a single file')
134
+ .action(async (filePath: string) => {
135
+ await withClient(async (ctx) => {
136
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
137
+ const result = await ctx.call('XcodeRefreshCodeIssuesInFile', {
138
+ tabIdentifier,
139
+ filePath,
140
+ });
141
+ printResult(result, ctx.output);
142
+ });
143
+ });
144
+
145
+ program
146
+ .command('build')
147
+ .description('Build current project in active scheme')
148
+ .action(async () => {
149
+ await withClient(async (ctx) => {
150
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
151
+ const result = await ctx.call('BuildProject', { tabIdentifier });
152
+ printResult(result, ctx.output);
153
+ });
154
+ });
155
+
156
+ program
157
+ .command('build-log')
158
+ .description('Show current or most recent build log')
159
+ .option('--glob <glob>', 'Filter log entries by file path glob')
160
+ .option('--pattern <regex>', 'Filter log entries by message/console regex')
161
+ .option('--severity <severity>', 'remark | warning | error', 'error')
162
+ .action(async (options: { glob?: string; pattern?: string; severity: string }) => {
163
+ await withClient(async (ctx) => {
164
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
165
+ const result = await ctx.call('GetBuildLog', {
166
+ tabIdentifier,
167
+ glob: options.glob,
168
+ pattern: options.pattern,
169
+ severity: options.severity,
170
+ });
171
+ printResult(result, ctx.output);
172
+ });
173
+ });
174
+
175
+ const tests = program.command('test').description('Run tests');
176
+
177
+ tests
178
+ .command('all')
179
+ .description('Run all tests from active test plan')
180
+ .action(async () => {
181
+ await withClient(async (ctx) => {
182
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
183
+ const result = await ctx.call('RunAllTests', { tabIdentifier });
184
+ printResult(result, ctx.output);
185
+ });
186
+ });
187
+
188
+ tests
189
+ .command('list')
190
+ .description("List tests from the active scheme's active test plan")
191
+ .action(async () => {
192
+ await withClient(async (ctx) => {
193
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
194
+ const result = await ctx.call('GetTestList', { tabIdentifier });
195
+ printResult(result, ctx.output);
196
+ });
197
+ });
198
+
199
+ tests
200
+ .command('some <tests...>')
201
+ .description('Run selected tests using target+identifier specifiers')
202
+ .option('--target <targetName>', 'Default test target for identifier-only specs')
203
+ .addHelpText(
204
+ 'after',
205
+ `
206
+ Examples:
207
+ xcode-cli test some "DashProxyTests::AccessKeyTests/testParseEndpointSimple()"
208
+ xcode-cli test some "DashProxyTests/AccessKeyTests/testParseEndpointSimple()"
209
+ xcode-cli test some --target DashProxyTests "AccessKeyTests#testParseEndpointSimple"
210
+ `,
211
+ )
212
+ .action(async (testsArg: string[], options: { target?: string }) => {
213
+ await withClient(async (ctx) => {
214
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
215
+ const parsed = testsArg.map((value) => parseTestSpecifier(value, options.target));
216
+ const tests = await resolveTestSpecifiers(parsed, ctx, tabIdentifier);
217
+ const result = await ctx.call('RunSomeTests', { tabIdentifier, tests });
218
+ printResult(result, ctx.output);
219
+ });
220
+ });
221
+
222
+ program
223
+ .command('ls [path]')
224
+ .description('List files/groups in Xcode project structure')
225
+ .option('-r, --recursive', 'List recursively')
226
+ .action(async (targetPath: string | undefined, options: { recursive?: boolean }) => {
227
+ await withClient(async (ctx) => {
228
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
229
+ const result = await ctx.call('XcodeLS', {
230
+ tabIdentifier,
231
+ path: targetPath ?? '/',
232
+ recursive: Boolean(options.recursive),
233
+ });
234
+ if (options.recursive && ctx.output !== 'json') {
235
+ const value = unwrapResult(result);
236
+ const tree = renderLsTree(value, targetPath ?? '/');
237
+ if (tree) {
238
+ console.log(tree);
239
+ return;
240
+ }
241
+ }
242
+ printResult(result, ctx.output);
243
+ });
244
+ });
245
+
246
+ program
247
+ .command('glob [pattern]')
248
+ .description('Find files by glob pattern in project structure')
249
+ .option('--path <path>', 'Base project path', '/')
250
+ .action(async (pattern: string | undefined, options: { path: string }) => {
251
+ await withClient(async (ctx) => {
252
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
253
+ const result = await ctx.call('XcodeGlob', {
254
+ tabIdentifier,
255
+ path: options.path,
256
+ pattern: pattern ?? '**/*',
257
+ });
258
+ printResult(result, ctx.output);
259
+ });
260
+ });
261
+
262
+ program
263
+ .command('read <filePath>')
264
+ .description('Read a file from Xcode project structure')
265
+ .option('--offset <offset>', 'Line offset', '0')
266
+ .option('--limit <limit>', 'Max lines', '300')
267
+ .action(async (filePath: string, options: { offset: string; limit: string }) => {
268
+ await withClient(async (ctx) => {
269
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
270
+ const result = await ctx.call('XcodeRead', {
271
+ tabIdentifier,
272
+ filePath,
273
+ offset: Number(options.offset),
274
+ limit: Number(options.limit),
275
+ });
276
+ printResult(result, ctx.output);
277
+ });
278
+ });
279
+
280
+ program
281
+ .command('grep <pattern>')
282
+ .description('Regex search across files in Xcode project structure')
283
+ .option('--glob <glob>', 'File glob filter')
284
+ .option('--head-limit <n>', 'Limit matches', '100')
285
+ .option('-i, --ignore-case', 'Case-insensitive pattern')
286
+ .action(async (pattern: string, options: { glob?: string; headLimit: string; ignoreCase?: boolean }) => {
287
+ await withClient(async (ctx) => {
288
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
289
+ const result = await ctx.call('XcodeGrep', {
290
+ tabIdentifier,
291
+ pattern,
292
+ glob: options.glob,
293
+ headLimit: Number(options.headLimit),
294
+ ignoreCase: Boolean(options.ignoreCase),
295
+ });
296
+ printResult(result, ctx.output);
297
+ });
298
+ });
299
+
300
+ program
301
+ .command('write <filePath> <content>')
302
+ .description('Create/overwrite file content in Xcode project structure')
303
+ .action(async (filePath: string, content: string) => {
304
+ await withClient(async (ctx) => {
305
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
306
+ const result = await ctx.call('XcodeWrite', { tabIdentifier, filePath, content });
307
+ printResult(result, ctx.output);
308
+ });
309
+ });
310
+
311
+ program
312
+ .command('update <filePath> <oldString> <newString>')
313
+ .description('Replace text in a file')
314
+ .option('--replace-all', 'Replace all occurrences')
315
+ .action(
316
+ async (
317
+ filePath: string,
318
+ oldString: string,
319
+ newString: string,
320
+ options: { replaceAll?: boolean },
321
+ ) => {
322
+ await withClient(async (ctx) => {
323
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
324
+ const result = await ctx.call('XcodeUpdate', {
325
+ tabIdentifier,
326
+ filePath,
327
+ oldString,
328
+ newString,
329
+ replaceAll: Boolean(options.replaceAll),
330
+ });
331
+ printResult(result, ctx.output);
332
+ });
333
+ },
334
+ );
335
+
336
+ program
337
+ .command('mkdir <directoryPath>')
338
+ .description('Create directory/group in Xcode project structure')
339
+ .action(async (directoryPath: string) => {
340
+ await withClient(async (ctx) => {
341
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
342
+ const result = await ctx.call('XcodeMakeDir', { tabIdentifier, directoryPath });
343
+ printResult(result, ctx.output);
344
+ });
345
+ });
346
+
347
+ program
348
+ .command('rm <targetPath>')
349
+ .description('Remove file/directory from project; optionally filesystem too')
350
+ .option('-r, --recursive', 'Recursive removal')
351
+ .option('--delete-files', 'Delete underlying files on disk')
352
+ .action(async (targetPath: string, options: { recursive?: boolean; deleteFiles?: boolean }) => {
353
+ await withClient(async (ctx) => {
354
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
355
+ const result = await ctx.call('XcodeRM', {
356
+ tabIdentifier,
357
+ path: targetPath,
358
+ recursive: Boolean(options.recursive),
359
+ deleteFiles: Boolean(options.deleteFiles),
360
+ });
361
+ printResult(result, ctx.output);
362
+ });
363
+ });
364
+
365
+ program
366
+ .command('mv <sourcePath> <destinationPath>')
367
+ .description('Move/rename/copy files in project structure')
368
+ .option('--copy', 'Copy instead of move')
369
+ .option('--overwrite', 'Overwrite destination if it exists')
370
+ .action(
371
+ async (
372
+ sourcePath: string,
373
+ destinationPath: string,
374
+ options: { copy?: boolean; overwrite?: boolean },
375
+ ) => {
376
+ await withClient(async (ctx) => {
377
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
378
+ const result = await ctx.call('XcodeMV', {
379
+ tabIdentifier,
380
+ sourcePath,
381
+ destinationPath,
382
+ operation: options.copy ? 'copy' : 'move',
383
+ overwriteExisting: Boolean(options.overwrite),
384
+ });
385
+ printResult(result, ctx.output);
386
+ });
387
+ },
388
+ );
389
+
390
+ program
391
+ .command('preview <sourceFilePath>')
392
+ .description('Render SwiftUI preview for a file')
393
+ .option('--index <n>', 'Preview definition index', '0')
394
+ .option('--render-timeout <seconds>', 'Render timeout seconds', '120')
395
+ .option('--out <path>', 'Write preview image to this path (or directory)')
396
+ .action(
397
+ async (
398
+ sourceFilePath: string,
399
+ options: { index: string; renderTimeout: string; out?: string },
400
+ ) => {
401
+ await withClient(async (ctx) => {
402
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
403
+ const result = await ctx.call('RenderPreview', {
404
+ tabIdentifier,
405
+ sourceFilePath,
406
+ previewDefinitionIndexInFile: Number(options.index),
407
+ timeout: Number(options.renderTimeout),
408
+ });
409
+
410
+ const raw = unwrapResult(result);
411
+ const sourceImagePath = findPreviewPath(raw);
412
+ if (!sourceImagePath) {
413
+ throw new Error(
414
+ `Preview rendered, but no output file path was found in response: ${JSON.stringify(raw)}`,
415
+ );
416
+ }
417
+
418
+ const outputPath = options.out
419
+ ? await copyPreviewToOutput(sourceImagePath, options.out)
420
+ : sourceImagePath;
421
+ console.log(outputPath);
422
+ });
423
+ },
424
+ );
425
+
426
+ program
427
+ .command('snippet <sourceFilePath> <codeSnippet>')
428
+ .description('Execute a Swift snippet in the context of a source file')
429
+ .option('--exec-timeout <seconds>', 'Snippet execution timeout seconds', '120')
430
+ .action(
431
+ async (
432
+ sourceFilePath: string,
433
+ codeSnippet: string,
434
+ options: { execTimeout: string },
435
+ ) => {
436
+ await withClient(async (ctx) => {
437
+ const tabIdentifier = await resolveTabIdentifier(ctx, true);
438
+ const result = await ctx.call('ExecuteSnippet', {
439
+ tabIdentifier,
440
+ sourceFilePath,
441
+ codeSnippet,
442
+ timeout: Number(options.execTimeout),
443
+ });
444
+ printResult(result, ctx.output);
445
+ });
446
+ },
447
+ );
448
+
449
+ program
450
+ .command('doc <query>')
451
+ .description('Search Apple docs via MCP docs search')
452
+ .option('--frameworks <list>', 'Comma-separated frameworks')
453
+ .action(async (query: string, options: { frameworks?: string }) => {
454
+ await withClient(async (ctx) => {
455
+ const frameworks = options.frameworks
456
+ ? options.frameworks
457
+ .split(',')
458
+ .map((value) => value.trim())
459
+ .filter(Boolean)
460
+ : undefined;
461
+ const result = await ctx.call('DocumentationSearch', { query, frameworks });
462
+ printResult(result, ctx.output);
463
+ });
464
+ });
465
+
466
+ program
467
+ .command('run <toolName>')
468
+ .description('Run any MCP tool directly with JSON args')
469
+ .requiredOption('--args <json>', 'JSON object with tool arguments')
470
+ .action(async (toolName: string, options: { args: string }) => {
471
+ await withClient(async (ctx) => {
472
+ const parsed = JSON.parse(options.args) as Record<string, unknown>;
473
+ const result = await ctx.call(toolName, parsed);
474
+ printResult(result, ctx.output);
475
+ });
476
+ });
477
+
478
+ applyCommandOrder(program, [
479
+ 'status',
480
+ 'build',
481
+ 'build-log',
482
+ 'test',
483
+ 'issues',
484
+ 'file-issues',
485
+ 'windows',
486
+ 'read',
487
+ 'grep',
488
+ 'ls',
489
+ 'glob',
490
+ 'write',
491
+ 'update',
492
+ 'mv',
493
+ 'mkdir',
494
+ 'rm',
495
+ 'preview',
496
+ 'snippet',
497
+ 'doc',
498
+ 'tools',
499
+ 'run',
500
+ ]);
501
+
502
+ program.parseAsync(process.argv).catch((error) => {
503
+ const issue = describeConnectionIssue(error);
504
+ if (issue.kind === 'http') {
505
+ console.error(`HTTP error${issue.statusCode ? ` (${issue.statusCode})` : ''}: ${issue.rawMessage}`);
506
+ } else if (issue.kind === 'timeout') {
507
+ console.error(`Timeout: ${issue.rawMessage}`);
508
+ } else if (issue.kind === 'auth') {
509
+ console.error(`Auth error: ${issue.rawMessage}`);
510
+ } else {
511
+ console.error(issue.rawMessage || String(error));
512
+ }
513
+ process.exit(1);
514
+ });
515
+
516
+ async function withClient(handler: (ctx: ClientContext) => Promise<void>) {
517
+ const root = program.opts<CommonOpts>();
518
+ const endpoint = root.url ?? process.env.XCODE_CLI_URL ?? DEFAULT_URL;
519
+ const timeoutMs = Number(root.timeout ?? '60000');
520
+ const output = root.json ? 'json' : parseOutputFormat(root.output ?? 'text');
521
+
522
+ const runtime = await createRuntime({
523
+ servers: [
524
+ {
525
+ name: SERVER_NAME,
526
+ description: 'xcode-tools',
527
+ command: {
528
+ kind: 'http',
529
+ url: new URL(endpoint),
530
+ },
531
+ },
532
+ ],
533
+ });
534
+
535
+ const proxy = createServerProxy(runtime, SERVER_NAME);
536
+ const call = async (toolName: string, args?: Record<string, unknown>) =>
537
+ proxy.call(toolName, { args, timeoutMs });
538
+
539
+ try {
540
+ await handler({
541
+ proxy,
542
+ output,
543
+ timeoutMs,
544
+ endpoint,
545
+ tabOverride: root.tab ?? process.env.XCODE_TAB_ID,
546
+ call,
547
+ });
548
+ } finally {
549
+ await runtime.close().catch(() => undefined);
550
+ }
551
+ }
552
+
553
+ async function resolveTabIdentifier(
554
+ ctx: Pick<ClientContext, 'tabOverride' | 'call'>,
555
+ autoDiscover: boolean,
556
+ windowsResult?: CallResult,
557
+ ): Promise<string> {
558
+ if (ctx.tabOverride) {
559
+ return ctx.tabOverride;
560
+ }
561
+ if (autoDiscover) {
562
+ const windows = windowsResult ?? (await ctx.call('XcodeListWindows'));
563
+ const discoveredTabIds = listTabIdentifiers(unwrapResult(windows));
564
+ if (discoveredTabIds.length === 1) {
565
+ return discoveredTabIds[0];
566
+ }
567
+ }
568
+ throw new Error(
569
+ 'No tab identifier found. Use --tab <id> (or XCODE_TAB_ID) or run `xcode-cli windows`.',
570
+ );
571
+ }
572
+
573
+ function listTabIdentifiers(value: unknown): string[] {
574
+ const tabIds = new Set<string>();
575
+ const queue: unknown[] = [value];
576
+ while (queue.length > 0) {
577
+ const current = queue.shift();
578
+ if (!current) {
579
+ continue;
580
+ }
581
+
582
+ if (typeof current === 'string') {
583
+ collectTabIdentifiersFromText(current, tabIds);
584
+ continue;
585
+ }
586
+
587
+ if (typeof current !== 'object') {
588
+ continue;
589
+ }
590
+
591
+ if (Array.isArray(current)) {
592
+ queue.push(...current);
593
+ continue;
594
+ }
595
+
596
+ const record = current as Record<string, unknown>;
597
+ for (const [key, entry] of Object.entries(record)) {
598
+ if (key === 'tabIdentifier' && typeof entry === 'string' && entry.trim()) {
599
+ tabIds.add(entry.trim());
600
+ }
601
+ if (typeof entry === 'string') {
602
+ collectTabIdentifiersFromText(entry, tabIds);
603
+ }
604
+ if (entry && typeof entry === 'object') {
605
+ queue.push(entry);
606
+ }
607
+ if (Array.isArray(entry)) {
608
+ queue.push(...entry);
609
+ }
610
+ }
611
+ }
612
+ return [...tabIds];
613
+ }
614
+
615
+ function collectTabIdentifiersFromText(text: string, sink: Set<string>) {
616
+ const regex = /tabIdentifier:\s*([^\s,]+)/g;
617
+ let match: RegExpExecArray | null;
618
+ while ((match = regex.exec(text)) !== null) {
619
+ const tabIdentifier = match[1]?.trim();
620
+ if (tabIdentifier) {
621
+ sink.add(tabIdentifier);
622
+ }
623
+ }
624
+ }
625
+
626
+ type NormalizedTestSpecifier = {
627
+ targetName: string;
628
+ testIdentifier: string;
629
+ };
630
+
631
+ type TestCatalogEntry = {
632
+ targetName: string;
633
+ identifier: string;
634
+ };
635
+
636
+ async function resolveTestSpecifiers(
637
+ parsed: ParsedTestSpecifier[],
638
+ ctx: Pick<ClientContext, 'call'>,
639
+ tabIdentifier: string,
640
+ ): Promise<NormalizedTestSpecifier[]> {
641
+ if (parsed.every((entry) => Boolean(entry.targetName))) {
642
+ return parsed.map((entry) => ({
643
+ targetName: entry.targetName!.trim(),
644
+ testIdentifier: entry.testIdentifier,
645
+ }));
646
+ }
647
+
648
+ const listResult = await ctx.call('GetTestList', { tabIdentifier });
649
+ const catalog = extractTestCatalog(unwrapResult(listResult));
650
+ const availableTargets = [...new Set(catalog.map((entry) => entry.targetName))].sort();
651
+ const byIdentifier = buildCatalogLookup(catalog);
652
+
653
+ return parsed.map((entry) => {
654
+ if (entry.targetName) {
655
+ return {
656
+ targetName: entry.targetName.trim(),
657
+ testIdentifier: entry.testIdentifier,
658
+ };
659
+ }
660
+
661
+ const candidates = resolveCatalogEntries(byIdentifier, entry.testIdentifier);
662
+ if (candidates.length === 0) {
663
+ const targetHint =
664
+ availableTargets.length > 0
665
+ ? ` Active scheme targets: ${availableTargets.join(', ')}.`
666
+ : ' Active scheme has no discoverable test targets.';
667
+ throw new Error(
668
+ `Unable to resolve target for '${entry.source}'. Run 'xcode-cli --tab ${tabIdentifier} test list --json' and use 'Target::Identifier'.${targetHint} If this test belongs to another scheme, switch active scheme in Xcode first.`,
669
+ );
670
+ }
671
+
672
+ const targetNames = [...new Set(candidates.map((candidate) => candidate.targetName))].sort();
673
+ if (targetNames.length > 1) {
674
+ throw new Error(
675
+ `Ambiguous test specifier '${entry.source}'. Matching targets: ${targetNames.join(', ')}. Use 'Target::${entry.testIdentifier}'.`,
676
+ );
677
+ }
678
+
679
+ return {
680
+ targetName: targetNames[0],
681
+ testIdentifier: candidates[0].identifier,
682
+ };
683
+ });
684
+ }
685
+
686
+ function extractTestCatalog(value: unknown): TestCatalogEntry[] {
687
+ const entries: TestCatalogEntry[] = [];
688
+ const queue: unknown[] = [value];
689
+ while (queue.length > 0) {
690
+ const current = queue.shift();
691
+ if (!current) {
692
+ continue;
693
+ }
694
+ if (Array.isArray(current)) {
695
+ queue.push(...current);
696
+ continue;
697
+ }
698
+ if (typeof current !== 'object') {
699
+ continue;
700
+ }
701
+ const record = current as Record<string, unknown>;
702
+ const targetName = typeof record.targetName === 'string' ? record.targetName.trim() : '';
703
+ const identifier = typeof record.identifier === 'string' ? record.identifier.trim() : '';
704
+ if (targetName && identifier) {
705
+ entries.push({ targetName, identifier });
706
+ }
707
+ for (const nested of Object.values(record)) {
708
+ if (!nested) {
709
+ continue;
710
+ }
711
+ if (Array.isArray(nested)) {
712
+ queue.push(...nested);
713
+ } else if (typeof nested === 'object') {
714
+ queue.push(nested);
715
+ }
716
+ }
717
+ }
718
+ return entries;
719
+ }
720
+
721
+ function buildCatalogLookup(catalog: TestCatalogEntry[]): Map<string, TestCatalogEntry[]> {
722
+ const lookup = new Map<string, TestCatalogEntry[]>();
723
+ for (const entry of catalog) {
724
+ for (const key of identifierLookupKeys(entry.identifier)) {
725
+ const existing = lookup.get(key);
726
+ if (existing) {
727
+ existing.push(entry);
728
+ } else {
729
+ lookup.set(key, [entry]);
730
+ }
731
+ }
732
+ }
733
+ return lookup;
734
+ }
735
+
736
+ function resolveCatalogEntries(
737
+ lookup: Map<string, TestCatalogEntry[]>,
738
+ testIdentifier: string,
739
+ ): TestCatalogEntry[] {
740
+ const matches = new Map<string, TestCatalogEntry>();
741
+ for (const key of identifierLookupKeys(testIdentifier)) {
742
+ const entries = lookup.get(key);
743
+ if (!entries) {
744
+ continue;
745
+ }
746
+ for (const entry of entries) {
747
+ matches.set(`${entry.targetName}::${entry.identifier}`, entry);
748
+ }
749
+ }
750
+ return [...matches.values()];
751
+ }
752
+
753
+ function identifierLookupKeys(identifier: string): string[] {
754
+ const trimmed = identifier.trim();
755
+ if (!trimmed) {
756
+ return [];
757
+ }
758
+ const keys = new Set<string>([trimmed]);
759
+ if (trimmed.endsWith('()')) {
760
+ keys.add(trimmed.slice(0, -2));
761
+ } else if (!trimmed.endsWith(')')) {
762
+ keys.add(`${trimmed}()`);
763
+ }
764
+ return [...keys];
765
+ }
766
+
767
+ function parseOutputFormat(value: string): CommonOpts['output'] {
768
+ const normalized = value.trim().toLowerCase();
769
+ if (normalized === 'text' || normalized === 'json') {
770
+ return normalized;
771
+ }
772
+ throw new Error(`Invalid output format '${value}'. Use 'text' or 'json'.`);
773
+ }
774
+
775
+ function applyCommandOrder(root: Command, names: string[]): void {
776
+ const weights = new Map<string, number>(names.map((name, index) => [name, index]));
777
+ root.commands.sort((a, b) => {
778
+ const aWeight = weights.get(a.name()) ?? Number.MAX_SAFE_INTEGER;
779
+ const bWeight = weights.get(b.name()) ?? Number.MAX_SAFE_INTEGER;
780
+ if (aWeight !== bWeight) {
781
+ return aWeight - bWeight;
782
+ }
783
+ return a.name().localeCompare(b.name());
784
+ });
785
+ }