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/LICENSE +21 -0
- package/README.md +151 -0
- package/bin/xcode-cli +19 -0
- package/bin/xcode-cli-ctl +19 -0
- package/package.json +41 -0
- package/skills/xcode-cli/SKILL.md +138 -0
- package/src/mcpbridge.ts +1624 -0
- package/src/xcode-ctl.ts +138 -0
- package/src/xcode-issues.ts +165 -0
- package/src/xcode-mcp.ts +483 -0
- package/src/xcode-output.ts +431 -0
- package/src/xcode-preview.ts +55 -0
- package/src/xcode-service.ts +278 -0
- package/src/xcode-skill.ts +52 -0
- package/src/xcode-test.ts +115 -0
- package/src/xcode-tree.ts +59 -0
- package/src/xcode-types.ts +28 -0
- package/src/xcode.ts +785 -0
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
|
+
}
|