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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_SERVICE_PORT = 48321;
|
|
8
|
+
|
|
9
|
+
const LAUNCHD_LABEL = 'com.xcode-cli.bridge';
|
|
10
|
+
const PLIST_FILENAME = `${LAUNCHD_LABEL}.plist`;
|
|
11
|
+
const LAUNCH_AGENTS_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
12
|
+
const PLIST_PATH = path.join(LAUNCH_AGENTS_DIR, PLIST_FILENAME);
|
|
13
|
+
const LOG_DIR = path.join(os.homedir(), 'Library', 'Logs');
|
|
14
|
+
const STDOUT_LOG = path.join(LOG_DIR, 'xcode-cli.stdout.log');
|
|
15
|
+
const STDERR_LOG = path.join(LOG_DIR, 'xcode-cli.stderr.log');
|
|
16
|
+
|
|
17
|
+
export type ServiceStatus = {
|
|
18
|
+
installed: boolean;
|
|
19
|
+
running: boolean;
|
|
20
|
+
pid?: number;
|
|
21
|
+
healthy?: boolean;
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function escapeXml(s: string): string {
|
|
26
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function generatePlist(binaryPath: string, port: number): string {
|
|
30
|
+
const envPath = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin';
|
|
31
|
+
return [
|
|
32
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
33
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"',
|
|
34
|
+
' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
35
|
+
'<plist version="1.0">',
|
|
36
|
+
'<dict>',
|
|
37
|
+
` <key>Label</key>`,
|
|
38
|
+
` <string>${escapeXml(LAUNCHD_LABEL)}</string>`,
|
|
39
|
+
` <key>ProgramArguments</key>`,
|
|
40
|
+
` <array>`,
|
|
41
|
+
` <string>${escapeXml(binaryPath)}</string>`,
|
|
42
|
+
` <string>run</string>`,
|
|
43
|
+
` <string>--port</string>`,
|
|
44
|
+
` <string>${port}</string>`,
|
|
45
|
+
` </array>`,
|
|
46
|
+
` <key>RunAtLoad</key>`,
|
|
47
|
+
` <true/>`,
|
|
48
|
+
` <key>StandardOutPath</key>`,
|
|
49
|
+
` <string>${escapeXml(STDOUT_LOG)}</string>`,
|
|
50
|
+
` <key>StandardErrorPath</key>`,
|
|
51
|
+
` <string>${escapeXml(STDERR_LOG)}</string>`,
|
|
52
|
+
` <key>EnvironmentVariables</key>`,
|
|
53
|
+
` <dict>`,
|
|
54
|
+
` <key>PATH</key>`,
|
|
55
|
+
` <string>${escapeXml(envPath)}</string>`,
|
|
56
|
+
` </dict>`,
|
|
57
|
+
'</dict>',
|
|
58
|
+
'</plist>',
|
|
59
|
+
'',
|
|
60
|
+
].join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function exec(command: string, args: string[], ignoreErrors = false): Promise<string> {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
execFile(command, args, (error, stdout, stderr) => {
|
|
66
|
+
if (error && !ignoreErrors) {
|
|
67
|
+
reject(new Error(`${command} ${args.join(' ')} failed: ${stderr || error.message}`));
|
|
68
|
+
} else {
|
|
69
|
+
resolve(stdout);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function uid(): string {
|
|
76
|
+
return String(process.getuid?.() ?? 501);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function resolveCtlBinaryPath(): Promise<string> {
|
|
80
|
+
const argv1 = process.argv[1];
|
|
81
|
+
if (argv1) {
|
|
82
|
+
const root = path.resolve(path.dirname(argv1), '..');
|
|
83
|
+
const candidate = path.join(root, 'bin', 'xcode-cli-ctl');
|
|
84
|
+
try {
|
|
85
|
+
await fs.access(candidate, fs.constants.X_OK);
|
|
86
|
+
return candidate;
|
|
87
|
+
} catch {
|
|
88
|
+
// fall through
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const result = (await exec('which', ['xcode-cli-ctl'])).trim();
|
|
94
|
+
if (result) return result;
|
|
95
|
+
} catch {
|
|
96
|
+
// fall through
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(
|
|
100
|
+
'Cannot resolve xcode-cli-ctl binary path. Ensure xcode-cli is installed globally.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function killPortOccupant(port: number): Promise<boolean> {
|
|
105
|
+
let lsofOutput: string;
|
|
106
|
+
try {
|
|
107
|
+
lsofOutput = (await exec('lsof', ['-ti', `:${port}`], true)).trim();
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (!lsofOutput) return false;
|
|
112
|
+
|
|
113
|
+
const pids = lsofOutput.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
114
|
+
for (const pid of pids) {
|
|
115
|
+
// Only kill if it's our own process
|
|
116
|
+
try {
|
|
117
|
+
const psOutput = await exec('ps', ['-p', pid, '-o', 'command='], true);
|
|
118
|
+
if (psOutput.includes('xcode-cli') || psOutput.includes('xcode.ts') || psOutput.includes('xcode-ctl')) {
|
|
119
|
+
await exec('kill', [pid], true);
|
|
120
|
+
} else {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Port ${port} is occupied by another process (pid ${pid}). Cannot start bridge.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof Error && error.message.includes('occupied by another')) {
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
// ps/kill failed, ignore
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function installService(options: { port?: number } = {}): Promise<void> {
|
|
136
|
+
const port = options.port ?? DEFAULT_SERVICE_PORT;
|
|
137
|
+
const binaryPath = await resolveCtlBinaryPath();
|
|
138
|
+
|
|
139
|
+
await fs.mkdir(LAUNCH_AGENTS_DIR, { recursive: true });
|
|
140
|
+
|
|
141
|
+
// Unload existing if present
|
|
142
|
+
try {
|
|
143
|
+
await fs.access(PLIST_PATH);
|
|
144
|
+
await exec('launchctl', ['bootout', `gui/${uid()}`, PLIST_PATH], true);
|
|
145
|
+
} catch {
|
|
146
|
+
// not installed yet
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const plist = generatePlist(binaryPath, port);
|
|
150
|
+
await fs.writeFile(PLIST_PATH, plist, 'utf8');
|
|
151
|
+
|
|
152
|
+
// bootstrap loads and starts (RunAtLoad=true)
|
|
153
|
+
await exec('launchctl', ['bootstrap', `gui/${uid()}`, PLIST_PATH]);
|
|
154
|
+
|
|
155
|
+
console.log(`Installed launchd service: ${LAUNCHD_LABEL}`);
|
|
156
|
+
console.log(`Plist: ${PLIST_PATH}`);
|
|
157
|
+
console.log(`Bridge: http://127.0.0.1:${port}/mcp`);
|
|
158
|
+
console.log(`Logs: ${STDERR_LOG}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function uninstallService(): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
await fs.access(PLIST_PATH);
|
|
164
|
+
} catch {
|
|
165
|
+
console.log('Service is not installed.');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await exec('launchctl', ['bootout', `gui/${uid()}`, PLIST_PATH], true);
|
|
170
|
+
await fs.unlink(PLIST_PATH);
|
|
171
|
+
console.log(`Removed launchd service: ${LAUNCHD_LABEL}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function startService(): Promise<void> {
|
|
175
|
+
try {
|
|
176
|
+
await fs.access(PLIST_PATH);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error('Service is not installed. Run: xcode-cli-ctl install');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await exec('launchctl', ['bootstrap', `gui/${uid()}`, PLIST_PATH], true);
|
|
182
|
+
console.log(`Started service: ${LAUNCHD_LABEL}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function stopService(): Promise<void> {
|
|
186
|
+
try {
|
|
187
|
+
await fs.access(PLIST_PATH);
|
|
188
|
+
} catch {
|
|
189
|
+
console.log('Service is not installed.');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await exec('launchctl', ['bootout', `gui/${uid()}`, PLIST_PATH], true);
|
|
194
|
+
console.log(`Stopped service: ${LAUNCHD_LABEL}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function restartService(): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
await fs.access(PLIST_PATH);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error('Service is not installed. Run: xcode-cli-ctl install');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await exec('launchctl', ['bootout', `gui/${uid()}`, PLIST_PATH], true);
|
|
205
|
+
await exec('launchctl', ['bootstrap', `gui/${uid()}`, PLIST_PATH]);
|
|
206
|
+
console.log(`Restarted service: ${LAUNCHD_LABEL}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function getServiceStatus(port = DEFAULT_SERVICE_PORT): Promise<ServiceStatus> {
|
|
210
|
+
let installed = false;
|
|
211
|
+
try {
|
|
212
|
+
await fs.access(PLIST_PATH);
|
|
213
|
+
installed = true;
|
|
214
|
+
} catch {
|
|
215
|
+
// not installed
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let running = false;
|
|
219
|
+
let pid: number | undefined;
|
|
220
|
+
if (installed) {
|
|
221
|
+
try {
|
|
222
|
+
const output = await exec(
|
|
223
|
+
'launchctl',
|
|
224
|
+
['print', `gui/${uid()}/${LAUNCHD_LABEL}`],
|
|
225
|
+
true,
|
|
226
|
+
);
|
|
227
|
+
if (output.includes('state = running')) {
|
|
228
|
+
running = true;
|
|
229
|
+
}
|
|
230
|
+
const pidMatch = output.match(/pid\s*=\s*(\d+)/);
|
|
231
|
+
if (pidMatch) {
|
|
232
|
+
pid = Number(pidMatch[1]);
|
|
233
|
+
running = true;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// not loaded
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let healthy: boolean | undefined;
|
|
241
|
+
const endpoint = `http://127.0.0.1:${port}/health`;
|
|
242
|
+
try {
|
|
243
|
+
const res = await fetch(endpoint);
|
|
244
|
+
healthy = res.ok;
|
|
245
|
+
} catch {
|
|
246
|
+
healthy = false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { installed, running, pid, healthy, endpoint: `http://127.0.0.1:${port}/mcp` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function printServiceStatus(port = DEFAULT_SERVICE_PORT): Promise<void> {
|
|
253
|
+
const status = await getServiceStatus(port);
|
|
254
|
+
|
|
255
|
+
if (!status.installed) {
|
|
256
|
+
console.log('Service: not installed');
|
|
257
|
+
console.log(`Run: xcode-cli-ctl install`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
console.log(`Service: ${status.running ? 'running' : 'stopped'}${status.pid ? ` (pid ${status.pid})` : ''}`);
|
|
262
|
+
console.log(`Healthy: ${status.healthy ? 'yes' : 'no'}`);
|
|
263
|
+
console.log(`Endpoint: ${status.endpoint}`);
|
|
264
|
+
console.log(`Plist: ${PLIST_PATH}`);
|
|
265
|
+
console.log(`Logs: ${STDERR_LOG}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function tailLogs(options: { lines?: number; follow?: boolean } = {}): void {
|
|
269
|
+
const lines = String(options.lines ?? 50);
|
|
270
|
+
const args = ['-n', lines];
|
|
271
|
+
if (options.follow) args.push('-f');
|
|
272
|
+
args.push(STDOUT_LOG, STDERR_LOG);
|
|
273
|
+
|
|
274
|
+
const child = spawn('tail', args, { stdio: 'inherit' });
|
|
275
|
+
child.on('error', (err) => {
|
|
276
|
+
console.error(`Failed to tail logs: ${err.message}`);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const SKILL_DIR_NAME = 'xcode-cli';
|
|
6
|
+
const SKILL_FILENAME = 'SKILL.md';
|
|
7
|
+
|
|
8
|
+
function getSkillSourcePath(): string {
|
|
9
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
10
|
+
const packageRoot = path.resolve(path.dirname(thisFile), '..');
|
|
11
|
+
return path.join(packageRoot, 'skills', SKILL_DIR_NAME, SKILL_FILENAME);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function installSkill(rootDir: string): Promise<void> {
|
|
15
|
+
const source = getSkillSourcePath();
|
|
16
|
+
try {
|
|
17
|
+
await fs.access(source);
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error(`Skill source not found at ${source}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const targetDir = path.join(rootDir, SKILL_DIR_NAME);
|
|
23
|
+
const targetFile = path.join(targetDir, SKILL_FILENAME);
|
|
24
|
+
|
|
25
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
26
|
+
await fs.copyFile(source, targetFile);
|
|
27
|
+
console.log(`Installed skill: ${targetFile}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function uninstallSkill(rootDir: string): Promise<void> {
|
|
31
|
+
const targetDir = path.join(rootDir, SKILL_DIR_NAME);
|
|
32
|
+
const targetFile = path.join(targetDir, SKILL_FILENAME);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await fs.access(targetFile);
|
|
36
|
+
} catch {
|
|
37
|
+
console.log(`Skill not found at ${targetFile}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await fs.unlink(targetFile);
|
|
42
|
+
// Remove directory if empty
|
|
43
|
+
try {
|
|
44
|
+
const entries = await fs.readdir(targetDir);
|
|
45
|
+
if (entries.length === 0) {
|
|
46
|
+
await fs.rmdir(targetDir);
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
console.log(`Removed skill: ${targetFile}`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export type ParsedTestSpecifier = {
|
|
2
|
+
source: string;
|
|
3
|
+
targetName?: string;
|
|
4
|
+
testIdentifier: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function parseTestSpecifier(input: string, defaultTargetName?: string): ParsedTestSpecifier {
|
|
8
|
+
const value = input.trim();
|
|
9
|
+
if (!value) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
`Invalid test specifier '${input}'. Expected formats: Target::Class/test(), Target/Class/test(), Class#test`,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const explicit = parseExplicitTargetAndIdentifier(value);
|
|
16
|
+
if (explicit) {
|
|
17
|
+
return {
|
|
18
|
+
source: input,
|
|
19
|
+
targetName: explicit.targetName,
|
|
20
|
+
testIdentifier: explicit.testIdentifier,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const hashIdentifier = parseHashIdentifier(value);
|
|
25
|
+
if (hashIdentifier) {
|
|
26
|
+
return {
|
|
27
|
+
source: input,
|
|
28
|
+
targetName: normalizeTarget(defaultTargetName),
|
|
29
|
+
testIdentifier: hashIdentifier,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (value.includes('/')) {
|
|
34
|
+
const slashCount = countCharacter(value, '/');
|
|
35
|
+
if (slashCount >= 2) {
|
|
36
|
+
const firstSlash = value.indexOf('/');
|
|
37
|
+
const targetName = value.slice(0, firstSlash).trim();
|
|
38
|
+
const testIdentifier = value.slice(firstSlash + 1).trim();
|
|
39
|
+
if (!targetName || !testIdentifier) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Invalid test specifier '${input}'. Expected formats: Target::Class/test(), Target/Class/test()`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
source: input,
|
|
46
|
+
targetName,
|
|
47
|
+
testIdentifier,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const firstSlash = value.indexOf('/');
|
|
52
|
+
if (firstSlash <= 0 || firstSlash >= value.length - 1) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Invalid test specifier '${input}'. Expected formats: Target::Class/test(), Target/Class/test(), Class#test`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
source: input,
|
|
60
|
+
targetName: normalizeTarget(defaultTargetName),
|
|
61
|
+
testIdentifier: value,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Invalid test specifier '${input}'. Expected formats: Target::Class/test(), Target/Class/test(), Class#test`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseExplicitTargetAndIdentifier(
|
|
71
|
+
value: string,
|
|
72
|
+
): { targetName: string; testIdentifier: string } | undefined {
|
|
73
|
+
const separator = value.indexOf('::');
|
|
74
|
+
if (separator <= 0) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const targetName = value.slice(0, separator).trim();
|
|
78
|
+
const identifierRaw = value.slice(separator + 2).trim();
|
|
79
|
+
if (!targetName || !identifierRaw) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
targetName,
|
|
84
|
+
testIdentifier: parseHashIdentifier(identifierRaw) ?? identifierRaw,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseHashIdentifier(value: string): string | undefined {
|
|
89
|
+
const separator = value.indexOf('#');
|
|
90
|
+
if (separator <= 0 || separator >= value.length - 1) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const suiteName = value.slice(0, separator).trim();
|
|
94
|
+
const methodName = value.slice(separator + 1).trim();
|
|
95
|
+
if (!suiteName || !methodName) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const normalizedMethod = methodName.endsWith('()') ? methodName : `${methodName}()`;
|
|
99
|
+
return `${suiteName}/${normalizedMethod}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeTarget(value: string | undefined): string | undefined {
|
|
103
|
+
const trimmed = value?.trim();
|
|
104
|
+
return trimmed ? trimmed : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function countCharacter(value: string, character: string): number {
|
|
108
|
+
let count = 0;
|
|
109
|
+
for (const entry of value) {
|
|
110
|
+
if (entry === character) {
|
|
111
|
+
count += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return count;
|
|
115
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
type TreeNode = {
|
|
4
|
+
children: Map<string, TreeNode>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function renderLsTree(value: unknown, rootLabel: string): string | null {
|
|
8
|
+
if (!value || typeof value !== 'object') {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const record = value as Record<string, unknown>;
|
|
12
|
+
if (!Array.isArray(record.items)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const items = record.items.filter((item): item is string => typeof item === 'string');
|
|
16
|
+
if (items.length === 0) {
|
|
17
|
+
return rootLabel;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rootName = path.basename(rootLabel.replace(/\/+$/, ''));
|
|
21
|
+
const parsed = items
|
|
22
|
+
.map((fullPath) => fullPath.replace(/\/+$/, '').split('/').filter(Boolean))
|
|
23
|
+
.filter((parts) => parts.length > 0);
|
|
24
|
+
const stripFirst =
|
|
25
|
+
rootName.length > 0 && parsed.every((parts) => parts.length > 0 && parts[0] === rootName);
|
|
26
|
+
|
|
27
|
+
const roots = new Map<string, TreeNode>();
|
|
28
|
+
for (const partsRaw of parsed) {
|
|
29
|
+
const parts = stripFirst ? partsRaw.slice(1) : partsRaw;
|
|
30
|
+
if (parts.length === 0) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
let current = roots;
|
|
34
|
+
for (const part of parts) {
|
|
35
|
+
if (!current.has(part)) {
|
|
36
|
+
current.set(part, { children: new Map() });
|
|
37
|
+
}
|
|
38
|
+
current = current.get(part)!.children;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const lines: string[] = [rootLabel];
|
|
43
|
+
appendTreeLines(roots, '', lines);
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function appendTreeLines(nodes: Map<string, TreeNode>, prefix: string, lines: string[]) {
|
|
48
|
+
const names = [...nodes.keys()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
|
49
|
+
names.forEach((name, index) => {
|
|
50
|
+
const isLast = index === names.length - 1;
|
|
51
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
52
|
+
lines.push(`${prefix}${connector}${name}`);
|
|
53
|
+
const childPrefix = `${prefix}${isLast ? ' ' : '│ '}`;
|
|
54
|
+
const child = nodes.get(name);
|
|
55
|
+
if (child && child.children.size > 0) {
|
|
56
|
+
appendTreeLines(child.children, childPrefix, lines);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CallResult } from 'mcporter';
|
|
2
|
+
|
|
3
|
+
export type CommonOpts = {
|
|
4
|
+
url?: string;
|
|
5
|
+
tab?: string;
|
|
6
|
+
timeout?: string;
|
|
7
|
+
output?: 'text' | 'json';
|
|
8
|
+
json?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type IssueEntry = {
|
|
12
|
+
path?: string;
|
|
13
|
+
message: string;
|
|
14
|
+
severity?: string;
|
|
15
|
+
line?: number;
|
|
16
|
+
column?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ClientContext = {
|
|
20
|
+
proxy: {
|
|
21
|
+
listTools: (args: { includeSchema: boolean }) => Promise<Array<{ name: string; description?: string }>>;
|
|
22
|
+
};
|
|
23
|
+
output: CommonOpts['output'];
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
endpoint: string;
|
|
26
|
+
tabOverride?: string;
|
|
27
|
+
call: (toolName: string, args?: Record<string, unknown>) => Promise<CallResult>;
|
|
28
|
+
};
|