@pickforge/picklab 0.1.0
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/dist/chunk-SGSRIPSM.js +3813 -0
- package/dist/picklab-mcp.js +7 -0
- package/dist/picklab.js +3164 -0
- package/package.json +32 -0
package/dist/picklab.js
ADDED
|
@@ -0,0 +1,3164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
agentsDir,
|
|
4
|
+
back,
|
|
5
|
+
buildCreateAvdArgs,
|
|
6
|
+
captureToTarget,
|
|
7
|
+
clearLogcat,
|
|
8
|
+
click,
|
|
9
|
+
createAndroidSession,
|
|
10
|
+
createDesktopSession,
|
|
11
|
+
deepMerge,
|
|
12
|
+
desktopSessionLogDir,
|
|
13
|
+
destroyAndroidSession,
|
|
14
|
+
destroyDesktopSession,
|
|
15
|
+
detectAndroidEnvironment,
|
|
16
|
+
detectScreenshotTool,
|
|
17
|
+
detectVncBinary,
|
|
18
|
+
ensureDir,
|
|
19
|
+
findOnPath,
|
|
20
|
+
findOnPath2,
|
|
21
|
+
getAndroidSessionStatus,
|
|
22
|
+
getDesktopSessionStatus,
|
|
23
|
+
getSession,
|
|
24
|
+
getUiTree,
|
|
25
|
+
globalConfigPath,
|
|
26
|
+
home,
|
|
27
|
+
installApk,
|
|
28
|
+
isValidSystemImageId,
|
|
29
|
+
launchApp,
|
|
30
|
+
launchApp2,
|
|
31
|
+
listAvds,
|
|
32
|
+
listRuns,
|
|
33
|
+
listSessions,
|
|
34
|
+
loadConfig,
|
|
35
|
+
logcat,
|
|
36
|
+
missingSdkMessage,
|
|
37
|
+
picklabHome,
|
|
38
|
+
pressKey,
|
|
39
|
+
projectConfigPath,
|
|
40
|
+
readConfigFile,
|
|
41
|
+
redactSecrets,
|
|
42
|
+
requireDisplay,
|
|
43
|
+
resolveRunnableSession,
|
|
44
|
+
resolveScreenshotTarget,
|
|
45
|
+
resolvedDefaults,
|
|
46
|
+
runAdb,
|
|
47
|
+
runCommand,
|
|
48
|
+
runMcpServe,
|
|
49
|
+
runsDir,
|
|
50
|
+
saveGlobalConfig,
|
|
51
|
+
saveProjectConfig,
|
|
52
|
+
screenshot,
|
|
53
|
+
screenshot2,
|
|
54
|
+
sdkmanagerInstallCommand,
|
|
55
|
+
tap,
|
|
56
|
+
typeText,
|
|
57
|
+
typeText2,
|
|
58
|
+
waitForWindow
|
|
59
|
+
} from "./chunk-SGSRIPSM.js";
|
|
60
|
+
|
|
61
|
+
// src/program.ts
|
|
62
|
+
import { createRequire } from "module";
|
|
63
|
+
import { Command, Option } from "commander";
|
|
64
|
+
|
|
65
|
+
// src/commands/android.ts
|
|
66
|
+
import path2 from "path";
|
|
67
|
+
import fs from "fs";
|
|
68
|
+
|
|
69
|
+
// src/commands/shared.ts
|
|
70
|
+
import path from "path";
|
|
71
|
+
function resolveProjectDir(opts) {
|
|
72
|
+
return path.resolve(opts.projectDir ?? process.cwd());
|
|
73
|
+
}
|
|
74
|
+
function parseIntArg(value, label) {
|
|
75
|
+
if (!/^\d+$/.test(value)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Invalid ${label} "${value}": expected a non-negative integer`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return Number(value);
|
|
81
|
+
}
|
|
82
|
+
async function runReported(opts, fn) {
|
|
83
|
+
let result;
|
|
84
|
+
try {
|
|
85
|
+
result = await fn();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
result = {
|
|
88
|
+
errors: [error instanceof Error ? error.message : String(error)]
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const errors = result.errors ?? [];
|
|
92
|
+
const report = { ok: errors.length === 0 };
|
|
93
|
+
for (const [key, value] of Object.entries(result.data ?? {})) {
|
|
94
|
+
if (key !== "ok" && key !== "errors") {
|
|
95
|
+
report[key] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
report.errors = errors;
|
|
99
|
+
if (opts.json === true) {
|
|
100
|
+
console.log(JSON.stringify(report, null, 2));
|
|
101
|
+
} else {
|
|
102
|
+
for (const line of result.lines ?? []) {
|
|
103
|
+
console.log(line);
|
|
104
|
+
}
|
|
105
|
+
for (const error of errors) {
|
|
106
|
+
console.error(`error: ${error}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return errors.length === 0 ? 0 : 1;
|
|
110
|
+
}
|
|
111
|
+
async function resolveSessionRecord(type, opts, env = process.env) {
|
|
112
|
+
return resolveRunnableSession(type, opts.session, {
|
|
113
|
+
env,
|
|
114
|
+
projectDir: resolveProjectDir(opts),
|
|
115
|
+
consumerLabel: "command",
|
|
116
|
+
createHint: `create one with: picklab session create --type ${type}`,
|
|
117
|
+
selectHint: "pick one with --session <id>"
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function resolveScreenshotTarget2(opts, defaultSlug, sessionId) {
|
|
121
|
+
return resolveScreenshotTarget({
|
|
122
|
+
projectDir: resolveProjectDir(opts),
|
|
123
|
+
out: opts.out,
|
|
124
|
+
runSlug: opts.run,
|
|
125
|
+
defaultSlug,
|
|
126
|
+
sessionId,
|
|
127
|
+
conflictError: "use either --out or --run, not both"
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/commands/android.ts
|
|
132
|
+
async function resolveAndroidTarget(opts) {
|
|
133
|
+
if (opts.serial !== void 0 && opts.session !== void 0) {
|
|
134
|
+
throw new Error("Pass either --session or --serial, not both");
|
|
135
|
+
}
|
|
136
|
+
if (opts.serial !== void 0) {
|
|
137
|
+
return { serial: opts.serial };
|
|
138
|
+
}
|
|
139
|
+
const record = await resolveSessionRecord("android", opts);
|
|
140
|
+
const serial = record.android?.serial;
|
|
141
|
+
if (serial === void 0) {
|
|
142
|
+
throw new Error(`Session ${record.id} has no device serial recorded`);
|
|
143
|
+
}
|
|
144
|
+
return { serial, sessionId: record.id };
|
|
145
|
+
}
|
|
146
|
+
function targetData(target) {
|
|
147
|
+
const data = { serial: target.serial };
|
|
148
|
+
if (target.sessionId !== void 0) {
|
|
149
|
+
data.sessionId = target.sessionId;
|
|
150
|
+
}
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
async function runAndroidInstallApk(apk, opts) {
|
|
154
|
+
return runReported(opts, async () => {
|
|
155
|
+
const target = await resolveAndroidTarget(opts);
|
|
156
|
+
const apkPath = path2.resolve(apk);
|
|
157
|
+
await installApk({ serial: target.serial, apkPath });
|
|
158
|
+
return {
|
|
159
|
+
data: { ...targetData(target), apkPath },
|
|
160
|
+
lines: [`installed ${apkPath} on ${target.serial}`]
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async function runAndroidLaunchApp(packageName, opts) {
|
|
165
|
+
return runReported(opts, async () => {
|
|
166
|
+
const target = await resolveAndroidTarget(opts);
|
|
167
|
+
await launchApp({
|
|
168
|
+
serial: target.serial,
|
|
169
|
+
packageName,
|
|
170
|
+
activity: opts.activity
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
data: { ...targetData(target), packageName },
|
|
174
|
+
lines: [`launched ${packageName} on ${target.serial}`]
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function runAndroidScreenshot(opts) {
|
|
179
|
+
return runReported(opts, async () => {
|
|
180
|
+
const target = await resolveAndroidTarget(opts);
|
|
181
|
+
const destination = await resolveScreenshotTarget2(
|
|
182
|
+
opts,
|
|
183
|
+
"android",
|
|
184
|
+
target.sessionId
|
|
185
|
+
);
|
|
186
|
+
const data = await captureToTarget(destination, async () => {
|
|
187
|
+
await screenshot({ serial: target.serial, outPath: destination.outPath });
|
|
188
|
+
});
|
|
189
|
+
Object.assign(data, targetData(target));
|
|
190
|
+
const lines = [`screenshot saved to ${destination.outPath}`];
|
|
191
|
+
if (data.runId !== void 0) {
|
|
192
|
+
lines.push(`run: ${data.runId}`);
|
|
193
|
+
}
|
|
194
|
+
return { data, lines };
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async function runAndroidTap(x, y, opts) {
|
|
198
|
+
return runReported(opts, async () => {
|
|
199
|
+
const target = await resolveAndroidTarget(opts);
|
|
200
|
+
const parsedX = parseIntArg(x, "x");
|
|
201
|
+
const parsedY = parseIntArg(y, "y");
|
|
202
|
+
await tap({ serial: target.serial, x: parsedX, y: parsedY });
|
|
203
|
+
return {
|
|
204
|
+
data: { ...targetData(target), x: parsedX, y: parsedY },
|
|
205
|
+
lines: [`tapped (${parsedX}, ${parsedY}) on ${target.serial}`]
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async function runAndroidType(text, opts) {
|
|
210
|
+
return runReported(opts, async () => {
|
|
211
|
+
const target = await resolveAndroidTarget(opts);
|
|
212
|
+
await typeText({ serial: target.serial, text });
|
|
213
|
+
return {
|
|
214
|
+
data: { ...targetData(target), length: text.length },
|
|
215
|
+
lines: [`typed ${text.length} character(s) on ${target.serial}`]
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
async function runAndroidBack(opts) {
|
|
220
|
+
return runReported(opts, async () => {
|
|
221
|
+
const target = await resolveAndroidTarget(opts);
|
|
222
|
+
await back({ serial: target.serial });
|
|
223
|
+
return {
|
|
224
|
+
data: targetData(target),
|
|
225
|
+
lines: [`pressed back on ${target.serial}`]
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
async function runAndroidHome(opts) {
|
|
230
|
+
return runReported(opts, async () => {
|
|
231
|
+
const target = await resolveAndroidTarget(opts);
|
|
232
|
+
await home({ serial: target.serial });
|
|
233
|
+
return {
|
|
234
|
+
data: targetData(target),
|
|
235
|
+
lines: [`pressed home on ${target.serial}`]
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async function runAndroidUiTree(opts) {
|
|
240
|
+
return runReported(opts, async () => {
|
|
241
|
+
const target = await resolveAndroidTarget(opts);
|
|
242
|
+
const xml = redactSecrets(await getUiTree({ serial: target.serial }));
|
|
243
|
+
if (opts.out !== void 0) {
|
|
244
|
+
const outPath = path2.resolve(opts.out);
|
|
245
|
+
await fs.promises.mkdir(path2.dirname(outPath), { recursive: true });
|
|
246
|
+
await fs.promises.writeFile(outPath, `${xml}
|
|
247
|
+
`, "utf8");
|
|
248
|
+
return {
|
|
249
|
+
data: { ...targetData(target), path: outPath },
|
|
250
|
+
lines: [`ui tree saved to ${outPath}`]
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { data: { ...targetData(target), xml }, lines: [xml] };
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async function runAndroidLogcat(opts) {
|
|
257
|
+
return runReported(opts, async () => {
|
|
258
|
+
const target = await resolveAndroidTarget(opts);
|
|
259
|
+
if (opts.clear === true) {
|
|
260
|
+
await clearLogcat({ serial: target.serial });
|
|
261
|
+
return {
|
|
262
|
+
data: { ...targetData(target), cleared: true },
|
|
263
|
+
lines: [`cleared logcat buffer on ${target.serial}`]
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const output = redactSecrets(
|
|
267
|
+
await logcat({
|
|
268
|
+
serial: target.serial,
|
|
269
|
+
lines: opts.lines === void 0 ? void 0 : parseIntArg(opts.lines, "--lines"),
|
|
270
|
+
filter: opts.filter
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
return {
|
|
274
|
+
data: { ...targetData(target), output },
|
|
275
|
+
lines: [output.replace(/\n$/, "")]
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async function runAndroidAdb(args, opts) {
|
|
280
|
+
try {
|
|
281
|
+
let serial;
|
|
282
|
+
let sessionId;
|
|
283
|
+
if (opts.serial !== void 0 || opts.session !== void 0) {
|
|
284
|
+
({ serial, sessionId } = await resolveAndroidTarget(opts));
|
|
285
|
+
} else {
|
|
286
|
+
const implicit = await resolveAndroidTarget(opts).then(
|
|
287
|
+
(target) => target,
|
|
288
|
+
async (error) => {
|
|
289
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
290
|
+
if (!message.startsWith("No running android session")) {
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
const running = (await listSessions()).filter(
|
|
294
|
+
(record) => record.type === "android" && record.status === "running"
|
|
295
|
+
);
|
|
296
|
+
if (running.length > 0) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"No running android session for this project, but other projects have running android sessions. Pass --session <id> or --serial <serial>, or run the command from the project that owns the session."
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return void 0;
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
serial = implicit?.serial;
|
|
305
|
+
sessionId = implicit?.sessionId;
|
|
306
|
+
}
|
|
307
|
+
const result = await runAdb(
|
|
308
|
+
serial === void 0 ? { args } : { serial, args }
|
|
309
|
+
);
|
|
310
|
+
if (opts.json === true) {
|
|
311
|
+
const report = {
|
|
312
|
+
ok: result.ok,
|
|
313
|
+
code: result.code,
|
|
314
|
+
stdout: result.stdout,
|
|
315
|
+
stderr: result.stderr,
|
|
316
|
+
errors: result.ok ? [] : [`adb exited with code ${result.code}`]
|
|
317
|
+
};
|
|
318
|
+
if (serial !== void 0) report.serial = serial;
|
|
319
|
+
if (sessionId !== void 0) report.sessionId = sessionId;
|
|
320
|
+
console.log(JSON.stringify(report, null, 2));
|
|
321
|
+
} else {
|
|
322
|
+
if (result.stdout !== "") process.stdout.write(result.stdout);
|
|
323
|
+
if (result.stderr !== "") process.stderr.write(result.stderr);
|
|
324
|
+
}
|
|
325
|
+
return result.ok ? 0 : result.code ?? 1;
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
328
|
+
if (opts.json === true) {
|
|
329
|
+
console.log(JSON.stringify({ ok: false, errors: [message] }, null, 2));
|
|
330
|
+
} else {
|
|
331
|
+
console.error(`error: ${message}`);
|
|
332
|
+
}
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/commands/artifacts.ts
|
|
338
|
+
import { spawn } from "child_process";
|
|
339
|
+
import path3 from "path";
|
|
340
|
+
async function runArtifactsList(opts) {
|
|
341
|
+
return runReported(opts, async () => {
|
|
342
|
+
const projectDir = resolveProjectDir(opts);
|
|
343
|
+
const manifests = await listRuns(projectDir);
|
|
344
|
+
const runs = manifests.map((manifest) => ({
|
|
345
|
+
runId: manifest.runId,
|
|
346
|
+
slug: manifest.slug,
|
|
347
|
+
createdAt: manifest.createdAt,
|
|
348
|
+
status: manifest.status,
|
|
349
|
+
artifacts: manifest.artifacts.length
|
|
350
|
+
}));
|
|
351
|
+
return {
|
|
352
|
+
data: { projectDir, runs },
|
|
353
|
+
lines: runs.length === 0 ? [`no runs found under ${runsDir(projectDir)}`] : runs.map(
|
|
354
|
+
(run) => `${run.runId} ${run.status} ${run.artifacts} artifact(s)`
|
|
355
|
+
)
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
var RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
360
|
+
function isSafeRunId(runId) {
|
|
361
|
+
return RUN_ID_PATTERN.test(runId) && runId !== "." && runId !== "..";
|
|
362
|
+
}
|
|
363
|
+
async function findRun(projectDir, runId) {
|
|
364
|
+
const manifests = (await listRuns(projectDir)).filter(
|
|
365
|
+
(candidate) => isSafeRunId(candidate.runId)
|
|
366
|
+
);
|
|
367
|
+
let manifest;
|
|
368
|
+
if (runId === void 0) {
|
|
369
|
+
manifest = manifests[0];
|
|
370
|
+
if (manifest === void 0) {
|
|
371
|
+
throw new Error(`No runs found under ${runsDir(projectDir)}`);
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
manifest = manifests.find((candidate) => candidate.runId === runId);
|
|
375
|
+
if (manifest === void 0) {
|
|
376
|
+
throw new Error(`Run not found: ${runId} (see: picklab artifacts list)`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { manifest, dir: path3.join(runsDir(projectDir), manifest.runId) };
|
|
380
|
+
}
|
|
381
|
+
async function runArtifactsOpen(runId, opts) {
|
|
382
|
+
return runReported(opts, async () => {
|
|
383
|
+
const projectDir = resolveProjectDir(opts);
|
|
384
|
+
const { manifest, dir } = await findRun(projectDir, runId);
|
|
385
|
+
let opened = false;
|
|
386
|
+
const display = process.env.DISPLAY;
|
|
387
|
+
if (opts.json !== true && display !== void 0 && display !== "") {
|
|
388
|
+
const xdgOpen = findOnPath2("xdg-open");
|
|
389
|
+
if (xdgOpen !== null) {
|
|
390
|
+
const child = spawn(xdgOpen, [dir], {
|
|
391
|
+
detached: true,
|
|
392
|
+
stdio: "ignore"
|
|
393
|
+
});
|
|
394
|
+
child.on("error", () => {
|
|
395
|
+
});
|
|
396
|
+
child.unref();
|
|
397
|
+
opened = true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
data: { runId: manifest.runId, dir, opened },
|
|
402
|
+
lines: [dir]
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function renderRunReport(manifest, dir) {
|
|
407
|
+
const lines = [
|
|
408
|
+
`# PickLab run ${manifest.runId}`,
|
|
409
|
+
"",
|
|
410
|
+
`- Slug: ${manifest.slug}`,
|
|
411
|
+
`- Status: ${manifest.status}`,
|
|
412
|
+
`- Created: ${manifest.createdAt}`
|
|
413
|
+
];
|
|
414
|
+
if (manifest.sessionId !== void 0) {
|
|
415
|
+
lines.push(`- Session: ${manifest.sessionId}`);
|
|
416
|
+
}
|
|
417
|
+
lines.push(
|
|
418
|
+
`- Directory: ${dir}`,
|
|
419
|
+
"",
|
|
420
|
+
`## Artifacts (${manifest.artifacts.length})`,
|
|
421
|
+
""
|
|
422
|
+
);
|
|
423
|
+
if (manifest.artifacts.length === 0) {
|
|
424
|
+
lines.push("(none)");
|
|
425
|
+
}
|
|
426
|
+
for (const artifact of manifest.artifacts) {
|
|
427
|
+
lines.push(
|
|
428
|
+
`- [${artifact.type}] ${artifact.name} \u2014 ${artifact.path} (${artifact.createdAt})`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return lines;
|
|
432
|
+
}
|
|
433
|
+
async function runArtifactsReport(runId, opts) {
|
|
434
|
+
return runReported(opts, async () => {
|
|
435
|
+
const projectDir = resolveProjectDir(opts);
|
|
436
|
+
const { manifest, dir } = await findRun(projectDir, runId);
|
|
437
|
+
return {
|
|
438
|
+
data: { runId: manifest.runId, dir, manifest },
|
|
439
|
+
lines: renderRunReport(manifest, dir)
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/commands/agents.ts
|
|
445
|
+
import fs10 from "fs";
|
|
446
|
+
|
|
447
|
+
// ../agent-installers/dist/index.js
|
|
448
|
+
import fs2 from "fs";
|
|
449
|
+
import path4 from "path";
|
|
450
|
+
import fs22 from "fs";
|
|
451
|
+
import path22 from "path";
|
|
452
|
+
import fs3 from "fs";
|
|
453
|
+
import path32 from "path";
|
|
454
|
+
import fs4 from "fs";
|
|
455
|
+
import fs5 from "fs";
|
|
456
|
+
import fs6 from "fs";
|
|
457
|
+
import fs7 from "fs";
|
|
458
|
+
import path42 from "path";
|
|
459
|
+
import os from "os";
|
|
460
|
+
import path5 from "path";
|
|
461
|
+
import path6 from "path";
|
|
462
|
+
import fs8 from "fs";
|
|
463
|
+
import path7 from "path";
|
|
464
|
+
import fs9 from "fs";
|
|
465
|
+
import path8 from "path";
|
|
466
|
+
var AGENT_KINDS = [
|
|
467
|
+
"codex",
|
|
468
|
+
"claude-code",
|
|
469
|
+
"cursor"
|
|
470
|
+
];
|
|
471
|
+
var tmpCounter = 0;
|
|
472
|
+
async function writeFileAtomic(filePath, content) {
|
|
473
|
+
const dir = path4.dirname(filePath);
|
|
474
|
+
await fs2.promises.mkdir(dir, { recursive: true });
|
|
475
|
+
tmpCounter += 1;
|
|
476
|
+
const tmp = path4.join(
|
|
477
|
+
dir,
|
|
478
|
+
`.${path4.basename(filePath)}.tmp-${process.pid}-${tmpCounter}`
|
|
479
|
+
);
|
|
480
|
+
let mode;
|
|
481
|
+
try {
|
|
482
|
+
mode = (await fs2.promises.stat(filePath)).mode & 511;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
const code = error.code;
|
|
485
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
await fs2.promises.writeFile(tmp, content, { encoding: "utf8", mode });
|
|
491
|
+
if (mode !== void 0) {
|
|
492
|
+
await fs2.promises.chmod(tmp, mode);
|
|
493
|
+
}
|
|
494
|
+
await fs2.promises.rename(tmp, filePath);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
await fs2.promises.rm(tmp, { force: true });
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function isPlainObject(value) {
|
|
501
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
502
|
+
}
|
|
503
|
+
function agentsStatePath(env = process.env) {
|
|
504
|
+
return path22.join(agentsDir(env), "state.json");
|
|
505
|
+
}
|
|
506
|
+
async function readAgentsState(env = process.env) {
|
|
507
|
+
let raw;
|
|
508
|
+
try {
|
|
509
|
+
raw = await fs22.promises.readFile(agentsStatePath(env), "utf8");
|
|
510
|
+
} catch (error) {
|
|
511
|
+
const code = error.code;
|
|
512
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
513
|
+
return { agents: {} };
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
let parsed;
|
|
518
|
+
try {
|
|
519
|
+
parsed = JSON.parse(raw);
|
|
520
|
+
} catch {
|
|
521
|
+
return { agents: {} };
|
|
522
|
+
}
|
|
523
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.agents)) {
|
|
524
|
+
return { agents: {} };
|
|
525
|
+
}
|
|
526
|
+
const agents = {};
|
|
527
|
+
for (const [name, value] of Object.entries(parsed.agents)) {
|
|
528
|
+
if (isPlainObject(value) && typeof value.registered === "boolean" && typeof value.configPath === "string") {
|
|
529
|
+
agents[name] = {
|
|
530
|
+
registered: value.registered,
|
|
531
|
+
configPath: value.configPath,
|
|
532
|
+
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : ""
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { agents };
|
|
537
|
+
}
|
|
538
|
+
async function recordAgentState(name, entry, env = process.env, now = /* @__PURE__ */ new Date()) {
|
|
539
|
+
const state = await readAgentsState(env);
|
|
540
|
+
state.agents[name] = { ...entry, updatedAt: now.toISOString() };
|
|
541
|
+
await writeFileAtomic(
|
|
542
|
+
agentsStatePath(env),
|
|
543
|
+
`${JSON.stringify(state, null, 2)}
|
|
544
|
+
`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
var MCP_SERVER_NAME = "picklab";
|
|
548
|
+
var SHARED_SNIPPET_BASENAMES = [
|
|
549
|
+
"picklab-mcp.json",
|
|
550
|
+
"picklab-mcp.toml"
|
|
551
|
+
];
|
|
552
|
+
function mcpServerEntry() {
|
|
553
|
+
return { command: "picklab", args: ["mcp", "serve"] };
|
|
554
|
+
}
|
|
555
|
+
function renderJsonSnippet(entry = mcpServerEntry()) {
|
|
556
|
+
const snippet = {
|
|
557
|
+
mcpServers: {
|
|
558
|
+
[MCP_SERVER_NAME]: { command: entry.command, args: entry.args }
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
return `${JSON.stringify(snippet, null, 2)}
|
|
562
|
+
`;
|
|
563
|
+
}
|
|
564
|
+
function renderTomlSnippet(entry = mcpServerEntry()) {
|
|
565
|
+
const args = entry.args.map((arg) => JSON.stringify(arg)).join(", ");
|
|
566
|
+
return `[mcp_servers.${MCP_SERVER_NAME}]
|
|
567
|
+
command = ${JSON.stringify(entry.command)}
|
|
568
|
+
args = [${args}]
|
|
569
|
+
`;
|
|
570
|
+
}
|
|
571
|
+
async function writeSharedSnippets(env = process.env) {
|
|
572
|
+
const dir = await ensureDir(agentsDir(env));
|
|
573
|
+
const jsonPath = path32.join(dir, SHARED_SNIPPET_BASENAMES[0]);
|
|
574
|
+
const tomlPath = path32.join(dir, SHARED_SNIPPET_BASENAMES[1]);
|
|
575
|
+
await fs3.promises.writeFile(jsonPath, renderJsonSnippet(), "utf8");
|
|
576
|
+
await fs3.promises.writeFile(tomlPath, renderTomlSnippet(), "utf8");
|
|
577
|
+
return { jsonPath, tomlPath };
|
|
578
|
+
}
|
|
579
|
+
var BACKUP_PATTERN = /\.picklab-backup-\d{8}-\d{6}(-\d+)?$/;
|
|
580
|
+
function timestamp(now) {
|
|
581
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
582
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
583
|
+
}
|
|
584
|
+
function isBackupPath(filePath) {
|
|
585
|
+
return BACKUP_PATTERN.test(filePath);
|
|
586
|
+
}
|
|
587
|
+
async function backupFile(filePath, now = /* @__PURE__ */ new Date()) {
|
|
588
|
+
const base = `${filePath}.picklab-backup-${timestamp(now)}`;
|
|
589
|
+
let candidate = base;
|
|
590
|
+
for (let attempt = 2; ; attempt += 1) {
|
|
591
|
+
try {
|
|
592
|
+
await fs4.promises.copyFile(
|
|
593
|
+
filePath,
|
|
594
|
+
candidate,
|
|
595
|
+
fs4.constants.COPYFILE_EXCL
|
|
596
|
+
);
|
|
597
|
+
return candidate;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
const code = error.code;
|
|
600
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
601
|
+
return void 0;
|
|
602
|
+
}
|
|
603
|
+
if (code !== "EEXIST") {
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
candidate = `${base}-${attempt}`;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function isPlainObject2(value) {
|
|
611
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
612
|
+
}
|
|
613
|
+
async function readTextIfExists(filePath) {
|
|
614
|
+
try {
|
|
615
|
+
return await fs5.promises.readFile(filePath, "utf8");
|
|
616
|
+
} catch (error) {
|
|
617
|
+
const code = error.code;
|
|
618
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
619
|
+
return void 0;
|
|
620
|
+
}
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function readJsonObject(filePath) {
|
|
625
|
+
const raw = await readTextIfExists(filePath);
|
|
626
|
+
if (raw === void 0) {
|
|
627
|
+
return void 0;
|
|
628
|
+
}
|
|
629
|
+
let parsed;
|
|
630
|
+
try {
|
|
631
|
+
parsed = JSON.parse(raw);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
`Refusing to edit ${filePath}: invalid JSON (${error.message}). Fix the file and retry.`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if (!isPlainObject2(parsed)) {
|
|
638
|
+
throw new Error(
|
|
639
|
+
`Refusing to edit ${filePath}: expected a top-level JSON object`
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
return parsed;
|
|
643
|
+
}
|
|
644
|
+
async function writeJsonObject(filePath, value) {
|
|
645
|
+
await writeFileAtomic(filePath, `${JSON.stringify(value, null, 2)}
|
|
646
|
+
`);
|
|
647
|
+
}
|
|
648
|
+
function entryMatches(current, entry) {
|
|
649
|
+
if (!isPlainObject2(current)) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
return current.command === entry.command && JSON.stringify(current.args) === JSON.stringify(entry.args);
|
|
653
|
+
}
|
|
654
|
+
async function mergeMcpServerIntoJsonFile(filePath, opts) {
|
|
655
|
+
const entry = opts.entry ?? mcpServerEntry();
|
|
656
|
+
const existing = await readJsonObject(filePath);
|
|
657
|
+
if (existing === void 0 && !opts.createIfMissing) {
|
|
658
|
+
throw new Error(`Config file not found: ${filePath}`);
|
|
659
|
+
}
|
|
660
|
+
const config = existing ?? {};
|
|
661
|
+
const servers = isPlainObject2(config.mcpServers) ? config.mcpServers : {};
|
|
662
|
+
if (entryMatches(servers[MCP_SERVER_NAME], entry)) {
|
|
663
|
+
return { configPath: filePath, changed: false };
|
|
664
|
+
}
|
|
665
|
+
const backupPath = existing === void 0 ? void 0 : await backupFile(filePath);
|
|
666
|
+
const next = {
|
|
667
|
+
...config,
|
|
668
|
+
mcpServers: {
|
|
669
|
+
...servers,
|
|
670
|
+
[MCP_SERVER_NAME]: { command: entry.command, args: entry.args }
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
await writeJsonObject(filePath, next);
|
|
674
|
+
return { configPath: filePath, changed: true, backupPath };
|
|
675
|
+
}
|
|
676
|
+
async function removeMcpServerFromJsonFile(filePath) {
|
|
677
|
+
const existing = await readJsonObject(filePath);
|
|
678
|
+
if (existing === void 0 || !isPlainObject2(existing.mcpServers) || !(MCP_SERVER_NAME in existing.mcpServers)) {
|
|
679
|
+
return { configPath: filePath, changed: false };
|
|
680
|
+
}
|
|
681
|
+
const backupPath = await backupFile(filePath);
|
|
682
|
+
const servers = { ...existing.mcpServers };
|
|
683
|
+
delete servers[MCP_SERVER_NAME];
|
|
684
|
+
const next = { ...existing };
|
|
685
|
+
if (Object.keys(servers).length === 0) {
|
|
686
|
+
delete next.mcpServers;
|
|
687
|
+
} else {
|
|
688
|
+
next.mcpServers = servers;
|
|
689
|
+
}
|
|
690
|
+
await writeJsonObject(filePath, next);
|
|
691
|
+
return { configPath: filePath, changed: true, backupPath };
|
|
692
|
+
}
|
|
693
|
+
async function jsonFileMcpServerState(filePath) {
|
|
694
|
+
let config;
|
|
695
|
+
try {
|
|
696
|
+
config = await readJsonObject(filePath);
|
|
697
|
+
} catch {
|
|
698
|
+
return "unknown";
|
|
699
|
+
}
|
|
700
|
+
if (config === void 0 || !isPlainObject2(config.mcpServers)) {
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
return isPlainObject2(config.mcpServers[MCP_SERVER_NAME]);
|
|
704
|
+
}
|
|
705
|
+
var TOML_MARKER_BEGIN = "# >>> picklab >>>";
|
|
706
|
+
var TOML_MARKER_END = "# <<< picklab <<<";
|
|
707
|
+
var SECTION_PATTERN = /^[ \t]*\[mcp_servers\.(?:picklab|"picklab")(?:\.[^\]\r\n]*)?\][ \t]*\r?$/m;
|
|
708
|
+
function escapeRegExp(value) {
|
|
709
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
710
|
+
}
|
|
711
|
+
function findMarkerLine(content, marker) {
|
|
712
|
+
const pattern = new RegExp(`^${escapeRegExp(marker)}[ \\t]*\\r?$`, "m");
|
|
713
|
+
const match = pattern.exec(content);
|
|
714
|
+
if (match === null) {
|
|
715
|
+
return void 0;
|
|
716
|
+
}
|
|
717
|
+
return { start: match.index, end: match.index + match[0].length };
|
|
718
|
+
}
|
|
719
|
+
async function readTextIfExists2(filePath) {
|
|
720
|
+
try {
|
|
721
|
+
return await fs6.promises.readFile(filePath, "utf8");
|
|
722
|
+
} catch (error) {
|
|
723
|
+
const code = error.code;
|
|
724
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
725
|
+
return void 0;
|
|
726
|
+
}
|
|
727
|
+
throw error;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function markerBlock(entry) {
|
|
731
|
+
return `${TOML_MARKER_BEGIN}
|
|
732
|
+
${renderTomlSnippet(entry)}${TOML_MARKER_END}
|
|
733
|
+
`;
|
|
734
|
+
}
|
|
735
|
+
function splitMarkers(content, filePath) {
|
|
736
|
+
const begin = findMarkerLine(content, TOML_MARKER_BEGIN);
|
|
737
|
+
const end = findMarkerLine(content, TOML_MARKER_END);
|
|
738
|
+
if (begin === void 0 && end === void 0) {
|
|
739
|
+
return { before: content, block: void 0, after: "" };
|
|
740
|
+
}
|
|
741
|
+
if (begin === void 0 || end === void 0 || end.start < begin.start) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Refusing to edit ${filePath}: unbalanced picklab markers ("${TOML_MARKER_BEGIN}" / "${TOML_MARKER_END}"). Fix the file and retry.`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
let blockEnd = end.end;
|
|
747
|
+
if (content[blockEnd] === "\n") {
|
|
748
|
+
blockEnd += 1;
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
before: content.slice(0, begin.start),
|
|
752
|
+
block: content.slice(begin.start, blockEnd),
|
|
753
|
+
after: content.slice(blockEnd)
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
function assertNoForeignSection(split, filePath) {
|
|
757
|
+
if (SECTION_PATTERN.test(split.before) || SECTION_PATTERN.test(split.after)) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`Refusing to edit ${filePath}: an [mcp_servers.picklab] section exists outside the picklab markers. Remove it (or move it between "${TOML_MARKER_BEGIN}" and "${TOML_MARKER_END}") and retry.`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async function upsertTomlMarkerBlock(filePath, entry) {
|
|
764
|
+
const existing = await readTextIfExists2(filePath);
|
|
765
|
+
const content = existing ?? "";
|
|
766
|
+
const split = splitMarkers(content, filePath);
|
|
767
|
+
assertNoForeignSection(split, filePath);
|
|
768
|
+
const desired = markerBlock(entry);
|
|
769
|
+
if (split.block === desired) {
|
|
770
|
+
return { configPath: filePath, changed: false };
|
|
771
|
+
}
|
|
772
|
+
let next;
|
|
773
|
+
if (split.block === void 0) {
|
|
774
|
+
const separator = content === "" ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
|
775
|
+
next = `${content}${separator}${desired}`;
|
|
776
|
+
} else {
|
|
777
|
+
next = `${split.before}${desired}${split.after}`;
|
|
778
|
+
}
|
|
779
|
+
const backupPath = existing === void 0 ? void 0 : await backupFile(filePath);
|
|
780
|
+
await writeFileAtomic(filePath, next);
|
|
781
|
+
return { configPath: filePath, changed: true, backupPath };
|
|
782
|
+
}
|
|
783
|
+
async function removeTomlMarkerBlock(filePath) {
|
|
784
|
+
const existing = await readTextIfExists2(filePath);
|
|
785
|
+
if (existing === void 0) {
|
|
786
|
+
return { configPath: filePath, changed: false };
|
|
787
|
+
}
|
|
788
|
+
const split = splitMarkers(existing, filePath);
|
|
789
|
+
if (split.block === void 0) {
|
|
790
|
+
return { configPath: filePath, changed: false };
|
|
791
|
+
}
|
|
792
|
+
const backupPath = await backupFile(filePath);
|
|
793
|
+
await writeFileAtomic(filePath, `${split.before}${split.after}`);
|
|
794
|
+
return { configPath: filePath, changed: true, backupPath };
|
|
795
|
+
}
|
|
796
|
+
async function inspectTomlFile(filePath) {
|
|
797
|
+
const existing = await readTextIfExists2(filePath);
|
|
798
|
+
if (existing === void 0) {
|
|
799
|
+
return {
|
|
800
|
+
exists: false,
|
|
801
|
+
markersPresent: false,
|
|
802
|
+
markersHaveSection: false,
|
|
803
|
+
foreignSection: false
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
let split;
|
|
807
|
+
try {
|
|
808
|
+
split = splitMarkers(existing, filePath);
|
|
809
|
+
} catch {
|
|
810
|
+
return {
|
|
811
|
+
exists: true,
|
|
812
|
+
markersPresent: true,
|
|
813
|
+
markersHaveSection: false,
|
|
814
|
+
foreignSection: false
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
exists: true,
|
|
819
|
+
markersPresent: split.block !== void 0,
|
|
820
|
+
markersHaveSection: split.block !== void 0 && SECTION_PATTERN.test(split.block),
|
|
821
|
+
foreignSection: SECTION_PATTERN.test(split.before) || SECTION_PATTERN.test(split.after)
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
async function tomlFileHasMcpServer(filePath) {
|
|
825
|
+
const inspection = await inspectTomlFile(filePath);
|
|
826
|
+
return inspection.markersHaveSection || inspection.foreignSection;
|
|
827
|
+
}
|
|
828
|
+
function homeDir(env) {
|
|
829
|
+
const fromEnv = env.HOME;
|
|
830
|
+
if (fromEnv !== void 0 && fromEnv !== "") {
|
|
831
|
+
return fromEnv;
|
|
832
|
+
}
|
|
833
|
+
return os.homedir();
|
|
834
|
+
}
|
|
835
|
+
var CLAUDE_CODE_MANUAL_COMMAND = "claude mcp add --scope user picklab -- picklab mcp serve";
|
|
836
|
+
var DIRECT_EDIT_WARNING = "the claude binary was not found on PATH, so the config file was edited directly; close Claude Code while linking, or it may overwrite the change";
|
|
837
|
+
function claudeCodeConfigPath(env = process.env) {
|
|
838
|
+
return path42.join(homeDir(env), ".claude.json");
|
|
839
|
+
}
|
|
840
|
+
function findClaudeBinary(env = process.env) {
|
|
841
|
+
for (const dir of (env.PATH ?? "").split(path42.delimiter)) {
|
|
842
|
+
if (dir === "") {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const candidate = path42.join(dir, "claude");
|
|
846
|
+
try {
|
|
847
|
+
fs7.accessSync(candidate, fs7.constants.X_OK);
|
|
848
|
+
if (fs7.statSync(candidate).isFile()) {
|
|
849
|
+
return candidate;
|
|
850
|
+
}
|
|
851
|
+
} catch {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return void 0;
|
|
856
|
+
}
|
|
857
|
+
function commandFailure(action, result) {
|
|
858
|
+
const output = result.stderr.trim() || result.stdout.trim();
|
|
859
|
+
return new Error(
|
|
860
|
+
`"claude mcp ${action}" failed (exit code ${result.code ?? "unknown"})` + (output === "" ? "" : `: ${output}`)
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
async function claudeCodeIsRegistered(configPath) {
|
|
864
|
+
return jsonFileMcpServerState(configPath);
|
|
865
|
+
}
|
|
866
|
+
async function linkClaudeCode(configPath, env = process.env) {
|
|
867
|
+
const claudeBin = findClaudeBinary(env);
|
|
868
|
+
if (claudeBin !== void 0) {
|
|
869
|
+
const result2 = await runCommand(
|
|
870
|
+
claudeBin,
|
|
871
|
+
[
|
|
872
|
+
"mcp",
|
|
873
|
+
"add",
|
|
874
|
+
"--scope",
|
|
875
|
+
"user",
|
|
876
|
+
"picklab",
|
|
877
|
+
"--",
|
|
878
|
+
"picklab",
|
|
879
|
+
"mcp",
|
|
880
|
+
"serve"
|
|
881
|
+
],
|
|
882
|
+
{ env: { ...env }, cleanEnv: true }
|
|
883
|
+
);
|
|
884
|
+
if (!result2.ok) {
|
|
885
|
+
throw commandFailure("add", result2);
|
|
886
|
+
}
|
|
887
|
+
return { configPath, changed: true };
|
|
888
|
+
}
|
|
889
|
+
let exists = false;
|
|
890
|
+
try {
|
|
891
|
+
await fs7.promises.access(configPath, fs7.constants.F_OK);
|
|
892
|
+
exists = true;
|
|
893
|
+
} catch {
|
|
894
|
+
exists = false;
|
|
895
|
+
}
|
|
896
|
+
if (!exists) {
|
|
897
|
+
return {
|
|
898
|
+
configPath,
|
|
899
|
+
changed: false,
|
|
900
|
+
instructions: `Claude Code config not found at ${configPath}; register manually with: ${CLAUDE_CODE_MANUAL_COMMAND}`
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const result = await mergeMcpServerIntoJsonFile(configPath, {
|
|
904
|
+
createIfMissing: false
|
|
905
|
+
});
|
|
906
|
+
return result.changed ? { ...result, warning: DIRECT_EDIT_WARNING } : result;
|
|
907
|
+
}
|
|
908
|
+
async function unlinkClaudeCode(configPath, env = process.env) {
|
|
909
|
+
const claudeBin = findClaudeBinary(env);
|
|
910
|
+
if (claudeBin !== void 0) {
|
|
911
|
+
const result2 = await runCommand(
|
|
912
|
+
claudeBin,
|
|
913
|
+
["mcp", "remove", "--scope", "user", "picklab"],
|
|
914
|
+
{ env: { ...env }, cleanEnv: true }
|
|
915
|
+
);
|
|
916
|
+
if (result2.ok) {
|
|
917
|
+
return { configPath, changed: true };
|
|
918
|
+
}
|
|
919
|
+
const output = `${result2.stdout}
|
|
920
|
+
${result2.stderr}`.toLowerCase();
|
|
921
|
+
if (output.includes("not found") || output.includes("no mcp server")) {
|
|
922
|
+
return { configPath, changed: false };
|
|
923
|
+
}
|
|
924
|
+
throw commandFailure("remove", result2);
|
|
925
|
+
}
|
|
926
|
+
const result = await removeMcpServerFromJsonFile(configPath);
|
|
927
|
+
return result.changed ? { ...result, warning: DIRECT_EDIT_WARNING } : result;
|
|
928
|
+
}
|
|
929
|
+
function codexConfigPath(env = process.env) {
|
|
930
|
+
const codexHome = env.CODEX_HOME;
|
|
931
|
+
if (codexHome !== void 0 && codexHome !== "") {
|
|
932
|
+
return path5.join(codexHome, "config.toml");
|
|
933
|
+
}
|
|
934
|
+
return path5.join(homeDir(env), ".codex", "config.toml");
|
|
935
|
+
}
|
|
936
|
+
async function codexIsRegistered(configPath) {
|
|
937
|
+
return tomlFileHasMcpServer(configPath);
|
|
938
|
+
}
|
|
939
|
+
async function linkCodex(configPath) {
|
|
940
|
+
return upsertTomlMarkerBlock(configPath);
|
|
941
|
+
}
|
|
942
|
+
async function unlinkCodex(configPath) {
|
|
943
|
+
return removeTomlMarkerBlock(configPath);
|
|
944
|
+
}
|
|
945
|
+
function cursorConfigPath(env = process.env) {
|
|
946
|
+
return path6.join(homeDir(env), ".cursor", "mcp.json");
|
|
947
|
+
}
|
|
948
|
+
async function cursorIsRegistered(configPath) {
|
|
949
|
+
return jsonFileMcpServerState(configPath);
|
|
950
|
+
}
|
|
951
|
+
async function linkCursor(configPath) {
|
|
952
|
+
return mergeMcpServerIntoJsonFile(configPath, { createIfMissing: true });
|
|
953
|
+
}
|
|
954
|
+
async function unlinkCursor(configPath) {
|
|
955
|
+
return removeMcpServerFromJsonFile(configPath);
|
|
956
|
+
}
|
|
957
|
+
var BUILTIN_AGENTS = {
|
|
958
|
+
codex: {
|
|
959
|
+
name: "codex",
|
|
960
|
+
defaultConfigPath: codexConfigPath,
|
|
961
|
+
isRegistered: codexIsRegistered,
|
|
962
|
+
link: linkCodex,
|
|
963
|
+
unlink: unlinkCodex
|
|
964
|
+
},
|
|
965
|
+
"claude-code": {
|
|
966
|
+
name: "claude-code",
|
|
967
|
+
defaultConfigPath: claudeCodeConfigPath,
|
|
968
|
+
isRegistered: claudeCodeIsRegistered,
|
|
969
|
+
link: linkClaudeCode,
|
|
970
|
+
unlink: unlinkClaudeCode
|
|
971
|
+
},
|
|
972
|
+
cursor: {
|
|
973
|
+
name: "cursor",
|
|
974
|
+
defaultConfigPath: cursorConfigPath,
|
|
975
|
+
isRegistered: cursorIsRegistered,
|
|
976
|
+
link: linkCursor,
|
|
977
|
+
unlink: unlinkCursor
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
function builtinAgent(name) {
|
|
981
|
+
return Object.prototype.hasOwnProperty.call(BUILTIN_AGENTS, name) ? BUILTIN_AGENTS[name] : void 0;
|
|
982
|
+
}
|
|
983
|
+
var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
984
|
+
var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
985
|
+
...AGENT_KINDS,
|
|
986
|
+
"state",
|
|
987
|
+
...SHARED_SNIPPET_BASENAMES.map(
|
|
988
|
+
(basename) => basename.replace(/\.[^.]+$/, "")
|
|
989
|
+
)
|
|
990
|
+
]);
|
|
991
|
+
function validateCustomAgentName(name) {
|
|
992
|
+
if (!NAME_PATTERN.test(name)) {
|
|
993
|
+
throw new Error(
|
|
994
|
+
`Invalid agent name "${name}": use letters, digits, ".", "_", or "-" (must start with a letter or digit)`
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
if (RESERVED_NAMES.has(name)) {
|
|
998
|
+
throw new Error(`Agent name "${name}" is reserved`);
|
|
999
|
+
}
|
|
1000
|
+
return name;
|
|
1001
|
+
}
|
|
1002
|
+
function parseMcpCommand(input) {
|
|
1003
|
+
const parts = input.trim().split(/\s+/).filter((part) => part !== "");
|
|
1004
|
+
const command = parts[0];
|
|
1005
|
+
if (command === void 0) {
|
|
1006
|
+
throw new Error("--mcp-command must not be empty");
|
|
1007
|
+
}
|
|
1008
|
+
return { command, args: parts.slice(1) };
|
|
1009
|
+
}
|
|
1010
|
+
function customAgentConfigPath(name, env = process.env) {
|
|
1011
|
+
return path7.join(agentsDir(env), `${name}.json`);
|
|
1012
|
+
}
|
|
1013
|
+
async function addCustomAgent(opts, env = process.env) {
|
|
1014
|
+
const name = validateCustomAgentName(opts.name);
|
|
1015
|
+
const entry = parseMcpCommand(opts.mcpCommand);
|
|
1016
|
+
await ensureDir(agentsDir(env));
|
|
1017
|
+
const configPath = customAgentConfigPath(name, env);
|
|
1018
|
+
if (opts.force !== true) {
|
|
1019
|
+
let exists = false;
|
|
1020
|
+
try {
|
|
1021
|
+
await fs8.promises.access(configPath, fs8.constants.F_OK);
|
|
1022
|
+
exists = true;
|
|
1023
|
+
} catch {
|
|
1024
|
+
exists = false;
|
|
1025
|
+
}
|
|
1026
|
+
if (exists) {
|
|
1027
|
+
throw new Error(
|
|
1028
|
+
`Custom agent "${name}" already exists at ${configPath} (re-run with --force to overwrite)`
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
await writeFileAtomic(configPath, renderJsonSnippet(entry));
|
|
1033
|
+
return { name, configPath, entry };
|
|
1034
|
+
}
|
|
1035
|
+
function isPlainObject3(value) {
|
|
1036
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1037
|
+
}
|
|
1038
|
+
function entryFromSnippet(raw) {
|
|
1039
|
+
let parsed;
|
|
1040
|
+
try {
|
|
1041
|
+
parsed = JSON.parse(raw);
|
|
1042
|
+
} catch {
|
|
1043
|
+
return void 0;
|
|
1044
|
+
}
|
|
1045
|
+
if (!isPlainObject3(parsed) || !isPlainObject3(parsed.mcpServers)) {
|
|
1046
|
+
return void 0;
|
|
1047
|
+
}
|
|
1048
|
+
const entry = parsed.mcpServers[MCP_SERVER_NAME];
|
|
1049
|
+
if (!isPlainObject3(entry) || typeof entry.command !== "string") {
|
|
1050
|
+
return void 0;
|
|
1051
|
+
}
|
|
1052
|
+
const args = Array.isArray(entry.args) ? entry.args.filter((arg) => typeof arg === "string") : [];
|
|
1053
|
+
return { command: entry.command, args };
|
|
1054
|
+
}
|
|
1055
|
+
async function listCustomAgents(env = process.env) {
|
|
1056
|
+
let entries;
|
|
1057
|
+
try {
|
|
1058
|
+
entries = await fs8.promises.readdir(agentsDir(env));
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
const code = error.code;
|
|
1061
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
1062
|
+
return [];
|
|
1063
|
+
}
|
|
1064
|
+
throw error;
|
|
1065
|
+
}
|
|
1066
|
+
const sharedBasenames = new Set(SHARED_SNIPPET_BASENAMES);
|
|
1067
|
+
const agents = [];
|
|
1068
|
+
for (const basename of entries.sort()) {
|
|
1069
|
+
if (!basename.endsWith(".json") || sharedBasenames.has(basename)) {
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
const configPath = path7.join(agentsDir(env), basename);
|
|
1073
|
+
let raw;
|
|
1074
|
+
try {
|
|
1075
|
+
raw = await fs8.promises.readFile(configPath, "utf8");
|
|
1076
|
+
} catch {
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
const entry = entryFromSnippet(raw);
|
|
1080
|
+
if (entry === void 0) {
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
agents.push({ name: basename.slice(0, -".json".length), configPath, entry });
|
|
1084
|
+
}
|
|
1085
|
+
return agents;
|
|
1086
|
+
}
|
|
1087
|
+
async function removeCustomAgent(name, env = process.env) {
|
|
1088
|
+
const configPath = customAgentConfigPath(validateCustomAgentName(name), env);
|
|
1089
|
+
try {
|
|
1090
|
+
await fs8.promises.unlink(configPath);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
const code = error.code;
|
|
1093
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
1094
|
+
return { configPath, changed: false };
|
|
1095
|
+
}
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
return { configPath, changed: true };
|
|
1099
|
+
}
|
|
1100
|
+
var BACKUP_CLUTTER_THRESHOLD = 3;
|
|
1101
|
+
async function checkAgentsDir(env, checks) {
|
|
1102
|
+
const dir = agentsDir(env);
|
|
1103
|
+
let entries;
|
|
1104
|
+
try {
|
|
1105
|
+
entries = await fs9.promises.readdir(dir);
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
const code = error.code;
|
|
1108
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
1109
|
+
checks.push({
|
|
1110
|
+
id: "agents-dir",
|
|
1111
|
+
status: "ok",
|
|
1112
|
+
detail: `${dir} does not exist yet (created on first link)`
|
|
1113
|
+
});
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
throw error;
|
|
1117
|
+
}
|
|
1118
|
+
const broken = [];
|
|
1119
|
+
for (const basename of entries.sort()) {
|
|
1120
|
+
const entryPath = path8.join(dir, basename);
|
|
1121
|
+
let stat;
|
|
1122
|
+
try {
|
|
1123
|
+
stat = await fs9.promises.lstat(entryPath);
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
const code = error.code;
|
|
1126
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
throw error;
|
|
1130
|
+
}
|
|
1131
|
+
if (!stat.isSymbolicLink()) {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
try {
|
|
1135
|
+
await fs9.promises.stat(entryPath);
|
|
1136
|
+
} catch {
|
|
1137
|
+
broken.push(entryPath);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (broken.length > 0) {
|
|
1141
|
+
checks.push({
|
|
1142
|
+
id: "agents-dir",
|
|
1143
|
+
status: "problem",
|
|
1144
|
+
detail: `broken symlink(s): ${broken.join(", ")}`
|
|
1145
|
+
});
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
checks.push({ id: "agents-dir", status: "ok", detail: dir });
|
|
1149
|
+
}
|
|
1150
|
+
async function countBackups(configPath) {
|
|
1151
|
+
const dir = path8.dirname(configPath);
|
|
1152
|
+
const base = path8.basename(configPath);
|
|
1153
|
+
let entries;
|
|
1154
|
+
try {
|
|
1155
|
+
entries = await fs9.promises.readdir(dir);
|
|
1156
|
+
} catch {
|
|
1157
|
+
return 0;
|
|
1158
|
+
}
|
|
1159
|
+
return entries.filter(
|
|
1160
|
+
(entry) => entry.startsWith(`${base}.picklab-backup-`) && isBackupPath(entry)
|
|
1161
|
+
).length;
|
|
1162
|
+
}
|
|
1163
|
+
async function fileExists(filePath) {
|
|
1164
|
+
try {
|
|
1165
|
+
await fs9.promises.access(filePath, fs9.constants.F_OK);
|
|
1166
|
+
return true;
|
|
1167
|
+
} catch {
|
|
1168
|
+
return false;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
function stateEntryForPath(stateEntry, configPath) {
|
|
1172
|
+
if (stateEntry === void 0) {
|
|
1173
|
+
return void 0;
|
|
1174
|
+
}
|
|
1175
|
+
return path8.resolve(stateEntry.configPath) === path8.resolve(configPath) ? stateEntry : void 0;
|
|
1176
|
+
}
|
|
1177
|
+
async function checkBuiltinAgent(name, configPath, checks, recordedEntry) {
|
|
1178
|
+
const id = `agent-${name}`;
|
|
1179
|
+
const stateEntry = stateEntryForPath(recordedEntry, configPath);
|
|
1180
|
+
const exists = await fileExists(configPath);
|
|
1181
|
+
const backups = await countBackups(configPath);
|
|
1182
|
+
if (!exists) {
|
|
1183
|
+
if (stateEntry?.registered === true) {
|
|
1184
|
+
checks.push({
|
|
1185
|
+
id,
|
|
1186
|
+
status: "problem",
|
|
1187
|
+
detail: `stale: PickLab linked ${configPath} but the file no longer exists (re-run: picklab agents link)`
|
|
1188
|
+
});
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
checks.push({
|
|
1192
|
+
id,
|
|
1193
|
+
status: "ok",
|
|
1194
|
+
detail: `not registered (${configPath} does not exist)`
|
|
1195
|
+
});
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
if (name === "codex") {
|
|
1199
|
+
const inspection = await inspectTomlFile(configPath);
|
|
1200
|
+
if (inspection.markersPresent && !inspection.markersHaveSection) {
|
|
1201
|
+
checks.push({
|
|
1202
|
+
id,
|
|
1203
|
+
status: "problem",
|
|
1204
|
+
detail: `stale: ${configPath} has picklab markers without an [mcp_servers.picklab] section (re-run: picklab agents link codex)`
|
|
1205
|
+
});
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (inspection.foreignSection && !inspection.markersPresent) {
|
|
1209
|
+
checks.push({
|
|
1210
|
+
id,
|
|
1211
|
+
status: "warn",
|
|
1212
|
+
detail: `${configPath} has an [mcp_servers.picklab] section that PickLab does not manage`
|
|
1213
|
+
});
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
pushRegistrationCheck(
|
|
1217
|
+
checks,
|
|
1218
|
+
id,
|
|
1219
|
+
configPath,
|
|
1220
|
+
inspection.markersHaveSection,
|
|
1221
|
+
backups,
|
|
1222
|
+
stateEntry
|
|
1223
|
+
);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const registered = await jsonFileMcpServerState(configPath);
|
|
1227
|
+
pushRegistrationCheck(checks, id, configPath, registered, backups, stateEntry);
|
|
1228
|
+
}
|
|
1229
|
+
function pushRegistrationCheck(checks, id, configPath, registered, backups, stateEntry) {
|
|
1230
|
+
if (registered === "unknown") {
|
|
1231
|
+
checks.push({
|
|
1232
|
+
id,
|
|
1233
|
+
status: "warn",
|
|
1234
|
+
detail: `${configPath} exists but is not parseable as strict JSON (JSONC comments or trailing commas?); cannot tell whether picklab is registered`
|
|
1235
|
+
});
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (!registered && stateEntry?.registered === true) {
|
|
1239
|
+
checks.push({
|
|
1240
|
+
id,
|
|
1241
|
+
status: "problem",
|
|
1242
|
+
detail: `stale: PickLab linked ${configPath} but the picklab entry is gone (re-run: picklab agents link)`
|
|
1243
|
+
});
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (backups > BACKUP_CLUTTER_THRESHOLD) {
|
|
1247
|
+
checks.push({
|
|
1248
|
+
id,
|
|
1249
|
+
status: "warn",
|
|
1250
|
+
detail: `${backups} picklab backups next to ${configPath}; consider pruning`
|
|
1251
|
+
});
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
if (!registered && stateEntry === void 0 && backups > 0) {
|
|
1255
|
+
checks.push({
|
|
1256
|
+
id,
|
|
1257
|
+
status: "warn",
|
|
1258
|
+
detail: `${configPath} has ${backups} picklab backup(s) but no picklab entry; it may have been edited outside PickLab (re-link with: picklab agents link)`
|
|
1259
|
+
});
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
checks.push({
|
|
1263
|
+
id,
|
|
1264
|
+
status: "ok",
|
|
1265
|
+
detail: registered ? `registered in ${configPath}` : stateEntry?.registered === false ? `not registered in ${configPath} (unlinked by picklab)` : `not registered in ${configPath}`
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
function checkPicklabOnPath(env, checks) {
|
|
1269
|
+
const pathValue = env.PATH ?? "";
|
|
1270
|
+
for (const dir of pathValue.split(path8.delimiter)) {
|
|
1271
|
+
if (dir === "") {
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
const candidate = path8.join(dir, "picklab");
|
|
1275
|
+
try {
|
|
1276
|
+
fs9.accessSync(candidate, fs9.constants.X_OK);
|
|
1277
|
+
if (fs9.statSync(candidate).isFile()) {
|
|
1278
|
+
checks.push({ id: "picklab-bin", status: "ok", detail: candidate });
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
} catch {
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
checks.push({
|
|
1286
|
+
id: "picklab-bin",
|
|
1287
|
+
status: "warn",
|
|
1288
|
+
detail: "picklab is not on PATH; registered agents will fail to start the MCP server (install globally or adjust PATH)"
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
async function runAgentsDoctor(opts = {}) {
|
|
1292
|
+
const env = opts.env ?? process.env;
|
|
1293
|
+
const checks = [];
|
|
1294
|
+
await checkAgentsDir(env, checks);
|
|
1295
|
+
const state = await readAgentsState(env);
|
|
1296
|
+
for (const agent of Object.values(BUILTIN_AGENTS)) {
|
|
1297
|
+
const configPath = opts.configPaths?.[agent.name] ?? agent.defaultConfigPath(env);
|
|
1298
|
+
await checkBuiltinAgent(
|
|
1299
|
+
agent.name,
|
|
1300
|
+
configPath,
|
|
1301
|
+
checks,
|
|
1302
|
+
state.agents[agent.name]
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
checkPicklabOnPath(env, checks);
|
|
1306
|
+
return {
|
|
1307
|
+
ok: !checks.some((check) => check.status === "problem"),
|
|
1308
|
+
checks
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/commands/agents.ts
|
|
1313
|
+
function parseConfigPathOverrides(values) {
|
|
1314
|
+
const overrides = {};
|
|
1315
|
+
for (const value of values ?? []) {
|
|
1316
|
+
const separator = value.indexOf("=");
|
|
1317
|
+
const agent = separator === -1 ? "" : value.slice(0, separator);
|
|
1318
|
+
const configPath = separator === -1 ? "" : value.slice(separator + 1);
|
|
1319
|
+
if (agent === "" || configPath === "") {
|
|
1320
|
+
throw new Error(
|
|
1321
|
+
`Invalid --config-path "${value}": expected <agent>=<path>`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
if (!AGENT_KINDS.includes(agent)) {
|
|
1325
|
+
throw new Error(
|
|
1326
|
+
`Invalid --config-path agent "${agent}" (expected one of: ${AGENT_KINDS.join(", ")})`
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
overrides[agent] = configPath;
|
|
1330
|
+
}
|
|
1331
|
+
return overrides;
|
|
1332
|
+
}
|
|
1333
|
+
async function fileExists2(filePath) {
|
|
1334
|
+
try {
|
|
1335
|
+
await fs10.promises.access(filePath, fs10.constants.F_OK);
|
|
1336
|
+
return true;
|
|
1337
|
+
} catch {
|
|
1338
|
+
return false;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
async function collectStatuses(env, overrides) {
|
|
1342
|
+
const statuses = [];
|
|
1343
|
+
for (const kind of AGENT_KINDS) {
|
|
1344
|
+
const agent = builtinAgent(kind);
|
|
1345
|
+
if (agent === void 0) continue;
|
|
1346
|
+
const configPath = overrides[kind] ?? agent.defaultConfigPath(env);
|
|
1347
|
+
statuses.push({
|
|
1348
|
+
name: kind,
|
|
1349
|
+
kind,
|
|
1350
|
+
configPath,
|
|
1351
|
+
configExists: await fileExists2(configPath),
|
|
1352
|
+
registered: await agent.isRegistered(configPath)
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
for (const custom of await listCustomAgents(env)) {
|
|
1356
|
+
statuses.push({
|
|
1357
|
+
name: custom.name,
|
|
1358
|
+
kind: "custom",
|
|
1359
|
+
configPath: custom.configPath,
|
|
1360
|
+
configExists: true,
|
|
1361
|
+
registered: true
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
return statuses;
|
|
1365
|
+
}
|
|
1366
|
+
function registrationLabel(registered) {
|
|
1367
|
+
if (registered === "unknown") {
|
|
1368
|
+
return "unknown (config exists but is not parseable as strict JSON)";
|
|
1369
|
+
}
|
|
1370
|
+
return registered ? "registered" : "not registered";
|
|
1371
|
+
}
|
|
1372
|
+
async function runAgentsList(opts, env = process.env) {
|
|
1373
|
+
return runReported(opts, async () => {
|
|
1374
|
+
const overrides = parseConfigPathOverrides(opts.configPath);
|
|
1375
|
+
const agents = await collectStatuses(env, overrides);
|
|
1376
|
+
return {
|
|
1377
|
+
data: { agents },
|
|
1378
|
+
lines: agents.map(
|
|
1379
|
+
(agent) => `${agent.name} ${agent.kind === "custom" ? "custom" : "builtin"} ${registrationLabel(agent.registered)} ${agent.configPath}`
|
|
1380
|
+
)
|
|
1381
|
+
};
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
async function unknownAgentError(name, env) {
|
|
1385
|
+
const customs = (await listCustomAgents(env)).map((agent) => agent.name);
|
|
1386
|
+
const known = [...AGENT_KINDS, ...customs].join(", ");
|
|
1387
|
+
return { errors: [`Unknown agent "${name}" (known agents: ${known})`] };
|
|
1388
|
+
}
|
|
1389
|
+
function changeLines(name, result, verb) {
|
|
1390
|
+
const lines = [];
|
|
1391
|
+
if (result.instructions !== void 0) {
|
|
1392
|
+
lines.push(result.instructions);
|
|
1393
|
+
} else if (result.changed) {
|
|
1394
|
+
lines.push(
|
|
1395
|
+
verb === "registered" ? `Registered the picklab MCP server for ${name} in ${result.configPath}` : `Removed the picklab MCP server entry for ${name} from ${result.configPath}`
|
|
1396
|
+
);
|
|
1397
|
+
} else {
|
|
1398
|
+
lines.push(
|
|
1399
|
+
verb === "registered" ? `${name} is already registered in ${result.configPath} (no changes made)` : `${name} has no picklab entry in ${result.configPath} (nothing to remove)`
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
if (result.backupPath !== void 0) {
|
|
1403
|
+
lines.push(`Backed up the previous config to ${result.backupPath}`);
|
|
1404
|
+
}
|
|
1405
|
+
if (result.warning !== void 0) {
|
|
1406
|
+
lines.push(`warning: ${result.warning}`);
|
|
1407
|
+
}
|
|
1408
|
+
return lines;
|
|
1409
|
+
}
|
|
1410
|
+
async function runAgentsLink(name, opts, env = process.env) {
|
|
1411
|
+
return runReported(opts, async () => {
|
|
1412
|
+
const agent = builtinAgent(name);
|
|
1413
|
+
if (agent === void 0) {
|
|
1414
|
+
const customs = await listCustomAgents(env);
|
|
1415
|
+
const custom = customs.find((candidate) => candidate.name === name);
|
|
1416
|
+
if (custom !== void 0) {
|
|
1417
|
+
return {
|
|
1418
|
+
errors: [
|
|
1419
|
+
`"${name}" is a custom agent; its MCP config snippet lives at ${custom.configPath}. Point the agent at it manually.`
|
|
1420
|
+
]
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
return unknownAgentError(name, env);
|
|
1424
|
+
}
|
|
1425
|
+
const snippets = await writeSharedSnippets(env);
|
|
1426
|
+
const configPath = opts.configPath ?? agent.defaultConfigPath(env);
|
|
1427
|
+
const result = await agent.link(configPath, env);
|
|
1428
|
+
const registered = await agent.isRegistered(configPath);
|
|
1429
|
+
if (result.instructions === void 0) {
|
|
1430
|
+
await recordAgentState(name, { registered: true, configPath }, env);
|
|
1431
|
+
}
|
|
1432
|
+
return {
|
|
1433
|
+
data: {
|
|
1434
|
+
agent: name,
|
|
1435
|
+
configPath,
|
|
1436
|
+
registered,
|
|
1437
|
+
changed: result.changed,
|
|
1438
|
+
backupPath: result.backupPath ?? null,
|
|
1439
|
+
instructions: result.instructions ?? null,
|
|
1440
|
+
warning: result.warning ?? null,
|
|
1441
|
+
snippets
|
|
1442
|
+
},
|
|
1443
|
+
lines: changeLines(name, result, "registered")
|
|
1444
|
+
};
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
async function runAgentsUnlink(name, opts, env = process.env) {
|
|
1448
|
+
return runReported(opts, async () => {
|
|
1449
|
+
const agent = builtinAgent(name);
|
|
1450
|
+
if (agent === void 0) {
|
|
1451
|
+
const customs = await listCustomAgents(env);
|
|
1452
|
+
if (customs.some((candidate) => candidate.name === name)) {
|
|
1453
|
+
const result2 = await removeCustomAgent(name, env);
|
|
1454
|
+
return {
|
|
1455
|
+
data: {
|
|
1456
|
+
agent: name,
|
|
1457
|
+
configPath: result2.configPath,
|
|
1458
|
+
changed: result2.changed
|
|
1459
|
+
},
|
|
1460
|
+
lines: [`Removed custom agent "${name}" (${result2.configPath})`]
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
return unknownAgentError(name, env);
|
|
1464
|
+
}
|
|
1465
|
+
const configPath = opts.configPath ?? agent.defaultConfigPath(env);
|
|
1466
|
+
const result = await agent.unlink(configPath, env);
|
|
1467
|
+
await recordAgentState(name, { registered: false, configPath }, env);
|
|
1468
|
+
return {
|
|
1469
|
+
data: {
|
|
1470
|
+
agent: name,
|
|
1471
|
+
configPath,
|
|
1472
|
+
changed: result.changed,
|
|
1473
|
+
backupPath: result.backupPath ?? null,
|
|
1474
|
+
warning: result.warning ?? null
|
|
1475
|
+
},
|
|
1476
|
+
lines: changeLines(name, result, "removed")
|
|
1477
|
+
};
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
async function runAgentsDoctorCommand(opts, env = process.env) {
|
|
1481
|
+
return runReported(opts, async () => {
|
|
1482
|
+
const configPaths = parseConfigPathOverrides(opts.configPath);
|
|
1483
|
+
const report = await runAgentsDoctor({ env, configPaths });
|
|
1484
|
+
const errors = report.checks.filter((check) => check.status === "problem").map((check) => `${check.id}: ${check.detail}`);
|
|
1485
|
+
return {
|
|
1486
|
+
data: { checks: report.checks },
|
|
1487
|
+
lines: report.checks.map(
|
|
1488
|
+
(check) => `[${check.status}] ${check.id}: ${check.detail}`
|
|
1489
|
+
),
|
|
1490
|
+
errors
|
|
1491
|
+
};
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
async function runAgentsAdd(opts, env = process.env) {
|
|
1495
|
+
return runReported(opts, async () => {
|
|
1496
|
+
if (opts.name === void 0 || opts.name === "") {
|
|
1497
|
+
return { errors: ["--name is required"] };
|
|
1498
|
+
}
|
|
1499
|
+
if (opts.mcpCommand === void 0) {
|
|
1500
|
+
return { errors: ["--mcp-command is required"] };
|
|
1501
|
+
}
|
|
1502
|
+
const agent = await addCustomAgent(
|
|
1503
|
+
{ name: opts.name, mcpCommand: opts.mcpCommand, force: opts.force },
|
|
1504
|
+
env
|
|
1505
|
+
);
|
|
1506
|
+
const snippets = await writeSharedSnippets(env);
|
|
1507
|
+
return {
|
|
1508
|
+
data: {
|
|
1509
|
+
name: agent.name,
|
|
1510
|
+
configPath: agent.configPath,
|
|
1511
|
+
command: agent.entry.command,
|
|
1512
|
+
args: agent.entry.args,
|
|
1513
|
+
snippets
|
|
1514
|
+
},
|
|
1515
|
+
lines: [
|
|
1516
|
+
`Added custom agent "${agent.name}" (${agent.configPath})`,
|
|
1517
|
+
`Command: ${[agent.entry.command, ...agent.entry.args].join(" ")}`,
|
|
1518
|
+
"Note: the command string is split on whitespace; quoting is not supported."
|
|
1519
|
+
]
|
|
1520
|
+
};
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/commands/desktop.ts
|
|
1525
|
+
async function resolveDesktop(opts) {
|
|
1526
|
+
const record = await resolveSessionRecord("desktop", opts);
|
|
1527
|
+
return { id: record.id, display: requireDisplay(record) };
|
|
1528
|
+
}
|
|
1529
|
+
async function runDesktopLaunch(command, args, opts) {
|
|
1530
|
+
return runReported(opts, async () => {
|
|
1531
|
+
const { id, display } = await resolveDesktop(opts);
|
|
1532
|
+
const app = await launchApp2({
|
|
1533
|
+
display,
|
|
1534
|
+
command,
|
|
1535
|
+
args,
|
|
1536
|
+
logDir: desktopSessionLogDir(id),
|
|
1537
|
+
cwd: opts.cwd
|
|
1538
|
+
});
|
|
1539
|
+
const data = {
|
|
1540
|
+
sessionId: id,
|
|
1541
|
+
display,
|
|
1542
|
+
pid: app.pid,
|
|
1543
|
+
logPath: app.logPath
|
|
1544
|
+
};
|
|
1545
|
+
const lines = [
|
|
1546
|
+
`launched ${command} (pid ${app.pid}) on ${display}`,
|
|
1547
|
+
`log: ${app.logPath}`
|
|
1548
|
+
];
|
|
1549
|
+
if (opts.waitWindow !== void 0) {
|
|
1550
|
+
const window = await waitForWindow(display, opts.waitWindow);
|
|
1551
|
+
data.window = window;
|
|
1552
|
+
lines.push(`window appeared: ${JSON.stringify(window.name)} (id ${window.id})`);
|
|
1553
|
+
}
|
|
1554
|
+
return { data, lines };
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
async function runDesktopScreenshot(opts) {
|
|
1558
|
+
return runReported(opts, async () => {
|
|
1559
|
+
const { id, display } = await resolveDesktop(opts);
|
|
1560
|
+
const target = await resolveScreenshotTarget2(opts, "desktop", id);
|
|
1561
|
+
let tool;
|
|
1562
|
+
const data = await captureToTarget(target, async () => {
|
|
1563
|
+
const result = await screenshot2({ display, outPath: target.outPath });
|
|
1564
|
+
tool = result.tool;
|
|
1565
|
+
});
|
|
1566
|
+
data.sessionId = id;
|
|
1567
|
+
data.display = display;
|
|
1568
|
+
data.tool = tool;
|
|
1569
|
+
const lines = [`screenshot saved to ${target.outPath}`];
|
|
1570
|
+
if (data.runId !== void 0) {
|
|
1571
|
+
lines.push(`run: ${data.runId}`);
|
|
1572
|
+
}
|
|
1573
|
+
return { data, lines };
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
async function runDesktopClick(x, y, opts) {
|
|
1577
|
+
return runReported(opts, async () => {
|
|
1578
|
+
const parsedX = parseIntArg(x, "x");
|
|
1579
|
+
const parsedY = parseIntArg(y, "y");
|
|
1580
|
+
const button = opts.button === void 0 ? void 0 : parseIntArg(opts.button, "--button");
|
|
1581
|
+
if (button !== void 0 && (button < 1 || button > 9)) {
|
|
1582
|
+
throw new Error(
|
|
1583
|
+
`Invalid --button "${opts.button}": expected an integer between 1 and 9`
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
const { id, display } = await resolveDesktop(opts);
|
|
1587
|
+
await click({ display, x: parsedX, y: parsedY, button });
|
|
1588
|
+
return {
|
|
1589
|
+
data: { sessionId: id, display, x: parsedX, y: parsedY, button: button ?? 1 },
|
|
1590
|
+
lines: [`clicked (${parsedX}, ${parsedY}) on ${display}`]
|
|
1591
|
+
};
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
async function runDesktopType(text, opts) {
|
|
1595
|
+
return runReported(opts, async () => {
|
|
1596
|
+
const { id, display } = await resolveDesktop(opts);
|
|
1597
|
+
await typeText2({ display, text });
|
|
1598
|
+
return {
|
|
1599
|
+
data: { sessionId: id, display, length: text.length },
|
|
1600
|
+
lines: [`typed ${text.length} character(s) on ${display}`]
|
|
1601
|
+
};
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
async function runDesktopKey(key, opts) {
|
|
1605
|
+
return runReported(opts, async () => {
|
|
1606
|
+
const { id, display } = await resolveDesktop(opts);
|
|
1607
|
+
await pressKey({ display, key });
|
|
1608
|
+
return {
|
|
1609
|
+
data: { sessionId: id, display, key },
|
|
1610
|
+
lines: [`pressed ${key} on ${display}`]
|
|
1611
|
+
};
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// src/provision/planner.ts
|
|
1616
|
+
import path9 from "path";
|
|
1617
|
+
var RECOMMENDED_SYSTEM_IMAGE = "system-images;android-35;google_apis;x86_64";
|
|
1618
|
+
var NOLOGIN_SHELL = "/usr/sbin/nologin";
|
|
1619
|
+
var LAB_USER_NAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
1620
|
+
function planLabUser(input) {
|
|
1621
|
+
if (!LAB_USER_NAME_PATTERN.test(input.name)) {
|
|
1622
|
+
return {
|
|
1623
|
+
ok: false,
|
|
1624
|
+
error: `Invalid lab user name "${input.name}": expected a POSIX user name (lowercase letters, digits, underscores, hyphens; max 32 chars)`
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
if (!path9.isAbsolute(input.home) || input.home !== path9.normalize(input.home)) {
|
|
1628
|
+
return {
|
|
1629
|
+
ok: false,
|
|
1630
|
+
error: `Invalid lab user home "${input.home}": expected a normalized absolute path`
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
const sudoArgs = (args) => input.nonInteractive === true ? ["-n", ...args] : args;
|
|
1634
|
+
const privilegedSpecs = [];
|
|
1635
|
+
if (!input.userExists) {
|
|
1636
|
+
privilegedSpecs.push({
|
|
1637
|
+
id: "useradd",
|
|
1638
|
+
title: `Create locked service user ${input.name}`,
|
|
1639
|
+
args: ["useradd", "-r", "-M", "-s", NOLOGIN_SHELL, input.name]
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
if (!input.homeExists) {
|
|
1643
|
+
privilegedSpecs.push({
|
|
1644
|
+
id: "mkdir-home",
|
|
1645
|
+
title: `Create lab home ${input.home}`,
|
|
1646
|
+
args: ["mkdir", "-p", input.home]
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
if (!input.userExists || !input.homeExists) {
|
|
1650
|
+
privilegedSpecs.push(
|
|
1651
|
+
{
|
|
1652
|
+
id: "chown-home",
|
|
1653
|
+
title: `Own lab home by ${input.name}`,
|
|
1654
|
+
args: ["chown", `${input.name}:${input.name}`, input.home]
|
|
1655
|
+
},
|
|
1656
|
+
{
|
|
1657
|
+
id: "chmod-home",
|
|
1658
|
+
title: "Restrict lab home permissions to 750",
|
|
1659
|
+
args: ["chmod", "750", input.home]
|
|
1660
|
+
}
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
if (!input.userExists && input.kvmPresent) {
|
|
1664
|
+
privilegedSpecs.push({
|
|
1665
|
+
id: "kvm-group",
|
|
1666
|
+
title: `Grant ${input.name} access to /dev/kvm`,
|
|
1667
|
+
args: ["usermod", "-aG", "kvm", input.name]
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
const steps = [];
|
|
1671
|
+
if (privilegedSpecs.length > 0) {
|
|
1672
|
+
const sudoPath = input.sudoPath;
|
|
1673
|
+
if (sudoPath === null) {
|
|
1674
|
+
return {
|
|
1675
|
+
ok: false,
|
|
1676
|
+
error: `sudo not found on PATH; cannot provision lab user "${input.name}". Install sudo, or create the user manually as root: useradd -r -M -s ${NOLOGIN_SHELL} ${input.name}`
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
steps.push(
|
|
1680
|
+
...privilegedSpecs.map(
|
|
1681
|
+
(spec) => ({
|
|
1682
|
+
id: spec.id,
|
|
1683
|
+
title: spec.title,
|
|
1684
|
+
kind: "command",
|
|
1685
|
+
privileged: true,
|
|
1686
|
+
command: { cmd: sudoPath, args: sudoArgs(spec.args) }
|
|
1687
|
+
})
|
|
1688
|
+
)
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
steps.push({
|
|
1692
|
+
id: "persist-lab-user",
|
|
1693
|
+
title: "Persist lab user in global PickLab config",
|
|
1694
|
+
kind: "write-global-config",
|
|
1695
|
+
privileged: false,
|
|
1696
|
+
config: { labUser: { name: input.name, home: input.home } }
|
|
1697
|
+
});
|
|
1698
|
+
return { ok: true, plan: { steps } };
|
|
1699
|
+
}
|
|
1700
|
+
var TAG_SCORES = {
|
|
1701
|
+
google_apis: 3,
|
|
1702
|
+
default: 2,
|
|
1703
|
+
google_apis_playstore: 1
|
|
1704
|
+
};
|
|
1705
|
+
var ABI_SCORES = {
|
|
1706
|
+
x86_64: 2,
|
|
1707
|
+
x86: 1
|
|
1708
|
+
};
|
|
1709
|
+
function apiLevel(image) {
|
|
1710
|
+
const match = /^android-(\d+)$/.exec(image.api);
|
|
1711
|
+
return match === null ? -1 : Number(match[1]);
|
|
1712
|
+
}
|
|
1713
|
+
function chooseSystemImage(images) {
|
|
1714
|
+
let best = null;
|
|
1715
|
+
for (const image of images) {
|
|
1716
|
+
if (best === null) {
|
|
1717
|
+
best = image;
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
const tagDiff = (TAG_SCORES[image.tag] ?? 0) - (TAG_SCORES[best.tag] ?? 0);
|
|
1721
|
+
if (tagDiff !== 0) {
|
|
1722
|
+
if (tagDiff > 0) best = image;
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
const abiDiff = (ABI_SCORES[image.abi] ?? 0) - (ABI_SCORES[best.abi] ?? 0);
|
|
1726
|
+
if (abiDiff !== 0) {
|
|
1727
|
+
if (abiDiff > 0) best = image;
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
if (apiLevel(image) > apiLevel(best)) {
|
|
1731
|
+
best = image;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
return best;
|
|
1735
|
+
}
|
|
1736
|
+
function planCreateAvd(input) {
|
|
1737
|
+
const persistStep = {
|
|
1738
|
+
id: "persist-avd",
|
|
1739
|
+
title: "Persist AVD name in global PickLab config",
|
|
1740
|
+
kind: "write-global-config",
|
|
1741
|
+
privileged: false,
|
|
1742
|
+
config: { android: { avdName: input.avdName } }
|
|
1743
|
+
};
|
|
1744
|
+
if (input.existingAvds.includes(input.avdName)) {
|
|
1745
|
+
return { ok: true, plan: { steps: [persistStep] } };
|
|
1746
|
+
}
|
|
1747
|
+
if (input.sdkRoot === null) {
|
|
1748
|
+
return { ok: false, error: missingSdkMessage() };
|
|
1749
|
+
}
|
|
1750
|
+
let systemImage;
|
|
1751
|
+
if (input.systemImage !== void 0) {
|
|
1752
|
+
if (!isValidSystemImageId(input.systemImage)) {
|
|
1753
|
+
return {
|
|
1754
|
+
ok: false,
|
|
1755
|
+
error: `Invalid system image "${input.systemImage}": expected the form "system-images;android-<api>;<tag>;<abi>"`
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
if (!input.installedImages.some(
|
|
1759
|
+
(image) => image.packageId === input.systemImage
|
|
1760
|
+
)) {
|
|
1761
|
+
return {
|
|
1762
|
+
ok: false,
|
|
1763
|
+
error: `System image "${input.systemImage}" is not installed under ${input.sdkRoot}. Install it with: ` + sdkmanagerInstallCommand(input.systemImage)
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
systemImage = input.systemImage;
|
|
1767
|
+
} else {
|
|
1768
|
+
const chosen = chooseSystemImage(input.installedImages);
|
|
1769
|
+
if (chosen === null) {
|
|
1770
|
+
return {
|
|
1771
|
+
ok: false,
|
|
1772
|
+
error: `No Android system images installed under ${input.sdkRoot}. Install one with: ` + sdkmanagerInstallCommand(RECOMMENDED_SYSTEM_IMAGE)
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
systemImage = chosen.packageId;
|
|
1776
|
+
}
|
|
1777
|
+
if (input.avdmanagerPath === null) {
|
|
1778
|
+
return {
|
|
1779
|
+
ok: false,
|
|
1780
|
+
error: `avdmanager not found under ${input.sdkRoot} or on PATH; install the Android command-line tools (https://developer.android.com/studio#command-line)`
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
let args;
|
|
1784
|
+
try {
|
|
1785
|
+
args = buildCreateAvdArgs({ name: input.avdName, systemImage });
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
return { ok: false, error: error.message };
|
|
1788
|
+
}
|
|
1789
|
+
return {
|
|
1790
|
+
ok: true,
|
|
1791
|
+
plan: {
|
|
1792
|
+
steps: [
|
|
1793
|
+
{
|
|
1794
|
+
id: "create-avd",
|
|
1795
|
+
title: `Create AVD ${input.avdName} (${systemImage})`,
|
|
1796
|
+
kind: "command",
|
|
1797
|
+
privileged: false,
|
|
1798
|
+
command: {
|
|
1799
|
+
cmd: input.avdmanagerPath,
|
|
1800
|
+
args,
|
|
1801
|
+
env: {
|
|
1802
|
+
ANDROID_HOME: input.sdkRoot,
|
|
1803
|
+
ANDROID_SDK_ROOT: input.sdkRoot
|
|
1804
|
+
},
|
|
1805
|
+
input: "no\n"
|
|
1806
|
+
}
|
|
1807
|
+
},
|
|
1808
|
+
persistStep
|
|
1809
|
+
]
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
function planPicklabHome(input) {
|
|
1814
|
+
if (input.exists) {
|
|
1815
|
+
return { steps: [] };
|
|
1816
|
+
}
|
|
1817
|
+
return {
|
|
1818
|
+
steps: [
|
|
1819
|
+
{
|
|
1820
|
+
id: "picklab-home",
|
|
1821
|
+
title: `Create PickLab home ${input.path}`,
|
|
1822
|
+
kind: "mkdir",
|
|
1823
|
+
privileged: false,
|
|
1824
|
+
dir: input.path
|
|
1825
|
+
}
|
|
1826
|
+
]
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// src/provision/checks.ts
|
|
1831
|
+
var BASE_CHECKS = ["picklab-home", "config"];
|
|
1832
|
+
var DESKTOP_CHECKS = [
|
|
1833
|
+
"xvfb",
|
|
1834
|
+
"xdotool",
|
|
1835
|
+
"screenshot-tool",
|
|
1836
|
+
"lab-user"
|
|
1837
|
+
];
|
|
1838
|
+
var ANDROID_CHECKS = [
|
|
1839
|
+
"android-sdk",
|
|
1840
|
+
"sdkmanager",
|
|
1841
|
+
"avdmanager",
|
|
1842
|
+
"emulator",
|
|
1843
|
+
"adb",
|
|
1844
|
+
"system-image",
|
|
1845
|
+
"avd"
|
|
1846
|
+
];
|
|
1847
|
+
var PROFILE_REQUIRED_CHECKS = {
|
|
1848
|
+
generic: [...BASE_CHECKS],
|
|
1849
|
+
"flutter-desktop": [...BASE_CHECKS, ...DESKTOP_CHECKS],
|
|
1850
|
+
android: [...BASE_CHECKS, ...ANDROID_CHECKS],
|
|
1851
|
+
"desktop+android": [...BASE_CHECKS, ...DESKTOP_CHECKS, ...ANDROID_CHECKS]
|
|
1852
|
+
};
|
|
1853
|
+
function requiredChecksForProfile(profile) {
|
|
1854
|
+
return PROFILE_REQUIRED_CHECKS[profile];
|
|
1855
|
+
}
|
|
1856
|
+
function pathCheck(id, title, found, hint, missingStatus = "missing") {
|
|
1857
|
+
if (found !== null) {
|
|
1858
|
+
return { id, title, status: "ok", detail: found };
|
|
1859
|
+
}
|
|
1860
|
+
return { id, title, status: missingStatus, detail: "not found", hint };
|
|
1861
|
+
}
|
|
1862
|
+
function evaluateChecks(s) {
|
|
1863
|
+
const checks = [];
|
|
1864
|
+
if (!s.picklabHome.exists) {
|
|
1865
|
+
checks.push({
|
|
1866
|
+
id: "picklab-home",
|
|
1867
|
+
title: "PickLab home",
|
|
1868
|
+
status: "missing",
|
|
1869
|
+
detail: `${s.picklabHome.path} does not exist`,
|
|
1870
|
+
hint: "run `picklab doctor --fix` or `picklab init` to create it"
|
|
1871
|
+
});
|
|
1872
|
+
} else if (!s.picklabHome.writable) {
|
|
1873
|
+
checks.push({
|
|
1874
|
+
id: "picklab-home",
|
|
1875
|
+
title: "PickLab home",
|
|
1876
|
+
status: "missing",
|
|
1877
|
+
detail: `${s.picklabHome.path} is not writable`,
|
|
1878
|
+
hint: `fix ownership/permissions of ${s.picklabHome.path}`
|
|
1879
|
+
});
|
|
1880
|
+
} else {
|
|
1881
|
+
checks.push({
|
|
1882
|
+
id: "picklab-home",
|
|
1883
|
+
title: "PickLab home",
|
|
1884
|
+
status: "ok",
|
|
1885
|
+
detail: s.picklabHome.path
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
if (s.config.ok) {
|
|
1889
|
+
checks.push({
|
|
1890
|
+
id: "config",
|
|
1891
|
+
title: "PickLab config",
|
|
1892
|
+
status: "ok",
|
|
1893
|
+
detail: s.config.profile === null ? "readable (no profile set)" : `readable (profile: ${s.config.profile})`
|
|
1894
|
+
});
|
|
1895
|
+
} else {
|
|
1896
|
+
checks.push({
|
|
1897
|
+
id: "config",
|
|
1898
|
+
title: "PickLab config",
|
|
1899
|
+
status: "missing",
|
|
1900
|
+
detail: s.config.error ?? "unreadable",
|
|
1901
|
+
hint: "fix or remove the broken config file"
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
checks.push(
|
|
1905
|
+
pathCheck(
|
|
1906
|
+
"xvfb",
|
|
1907
|
+
"Xvfb (headless X server)",
|
|
1908
|
+
s.desktop.xvfb,
|
|
1909
|
+
"install Xvfb (e.g. xorg-server-xvfb / xvfb package)"
|
|
1910
|
+
),
|
|
1911
|
+
pathCheck(
|
|
1912
|
+
"xdotool",
|
|
1913
|
+
"xdotool (input synthesis)",
|
|
1914
|
+
s.desktop.xdotool,
|
|
1915
|
+
"install xdotool"
|
|
1916
|
+
),
|
|
1917
|
+
pathCheck(
|
|
1918
|
+
"screenshot-tool",
|
|
1919
|
+
"Screenshot tool",
|
|
1920
|
+
s.desktop.screenshotTool,
|
|
1921
|
+
"install ImageMagick (provides `import`) or scrot"
|
|
1922
|
+
),
|
|
1923
|
+
pathCheck(
|
|
1924
|
+
"x11vnc",
|
|
1925
|
+
"x11vnc (optional live view)",
|
|
1926
|
+
s.desktop.x11vnc,
|
|
1927
|
+
"optional: install x11vnc to watch lab sessions live",
|
|
1928
|
+
"warn"
|
|
1929
|
+
),
|
|
1930
|
+
pathCheck(
|
|
1931
|
+
"android-sdk",
|
|
1932
|
+
"Android SDK",
|
|
1933
|
+
s.android.sdkRoot,
|
|
1934
|
+
missingSdkMessage()
|
|
1935
|
+
),
|
|
1936
|
+
pathCheck(
|
|
1937
|
+
"sdkmanager",
|
|
1938
|
+
"sdkmanager",
|
|
1939
|
+
s.android.tools.sdkmanager,
|
|
1940
|
+
"install the Android command-line tools (cmdline-tools)"
|
|
1941
|
+
),
|
|
1942
|
+
pathCheck(
|
|
1943
|
+
"avdmanager",
|
|
1944
|
+
"avdmanager",
|
|
1945
|
+
s.android.tools.avdmanager,
|
|
1946
|
+
"install the Android command-line tools (cmdline-tools)"
|
|
1947
|
+
),
|
|
1948
|
+
pathCheck(
|
|
1949
|
+
"emulator",
|
|
1950
|
+
"Android emulator",
|
|
1951
|
+
s.android.tools.emulator,
|
|
1952
|
+
'install the emulator package: sdkmanager "emulator"'
|
|
1953
|
+
),
|
|
1954
|
+
pathCheck(
|
|
1955
|
+
"adb",
|
|
1956
|
+
"adb",
|
|
1957
|
+
s.android.tools.adb,
|
|
1958
|
+
'install platform-tools: sdkmanager "platform-tools"'
|
|
1959
|
+
)
|
|
1960
|
+
);
|
|
1961
|
+
if (s.android.systemImages.length > 0) {
|
|
1962
|
+
checks.push({
|
|
1963
|
+
id: "system-image",
|
|
1964
|
+
title: "Android system images",
|
|
1965
|
+
status: "ok",
|
|
1966
|
+
detail: `${s.android.systemImages.length} installed`
|
|
1967
|
+
});
|
|
1968
|
+
} else {
|
|
1969
|
+
checks.push({
|
|
1970
|
+
id: "system-image",
|
|
1971
|
+
title: "Android system images",
|
|
1972
|
+
status: "missing",
|
|
1973
|
+
detail: "no system images installed",
|
|
1974
|
+
hint: `install one with: ${sdkmanagerInstallCommand(RECOMMENDED_SYSTEM_IMAGE)}`
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
if (s.android.kvm.supported) {
|
|
1978
|
+
checks.push({
|
|
1979
|
+
id: "kvm",
|
|
1980
|
+
title: "KVM hardware acceleration",
|
|
1981
|
+
status: "ok",
|
|
1982
|
+
detail: "/dev/kvm is accessible"
|
|
1983
|
+
});
|
|
1984
|
+
} else if (s.android.kvm.exists) {
|
|
1985
|
+
checks.push({
|
|
1986
|
+
id: "kvm",
|
|
1987
|
+
title: "KVM hardware acceleration",
|
|
1988
|
+
status: "warn",
|
|
1989
|
+
detail: "/dev/kvm exists but is not accessible",
|
|
1990
|
+
hint: "add your user to the kvm group, then log in again"
|
|
1991
|
+
});
|
|
1992
|
+
} else {
|
|
1993
|
+
checks.push({
|
|
1994
|
+
id: "kvm",
|
|
1995
|
+
title: "KVM hardware acceleration",
|
|
1996
|
+
status: "warn",
|
|
1997
|
+
detail: "/dev/kvm not found",
|
|
1998
|
+
hint: "without KVM the Android emulator will be very slow"
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
if (s.android.avdExists) {
|
|
2002
|
+
checks.push({
|
|
2003
|
+
id: "avd",
|
|
2004
|
+
title: "Dedicated PickLab AVD",
|
|
2005
|
+
status: "ok",
|
|
2006
|
+
detail: s.android.avdName
|
|
2007
|
+
});
|
|
2008
|
+
} else {
|
|
2009
|
+
checks.push({
|
|
2010
|
+
id: "avd",
|
|
2011
|
+
title: "Dedicated PickLab AVD",
|
|
2012
|
+
status: "missing",
|
|
2013
|
+
detail: `AVD "${s.android.avdName}" not found`,
|
|
2014
|
+
hint: `create it with: picklab setup android --create-avd --avd-name ${s.android.avdName}`
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
if (s.labUser.exists) {
|
|
2018
|
+
checks.push({
|
|
2019
|
+
id: "lab-user",
|
|
2020
|
+
title: "Dedicated lab user",
|
|
2021
|
+
status: "ok",
|
|
2022
|
+
detail: s.labUser.name
|
|
2023
|
+
});
|
|
2024
|
+
} else {
|
|
2025
|
+
checks.push({
|
|
2026
|
+
id: "lab-user",
|
|
2027
|
+
title: "Dedicated lab user",
|
|
2028
|
+
status: "missing",
|
|
2029
|
+
detail: `user "${s.labUser.name}" not found`,
|
|
2030
|
+
hint: `create it with: picklab setup lab-user --name ${s.labUser.name}`
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
return checks;
|
|
2034
|
+
}
|
|
2035
|
+
function formatCheckLine(check) {
|
|
2036
|
+
const status = `[${check.status}]`.padEnd(10);
|
|
2037
|
+
const line = `${status}${check.id.padEnd(18)}${check.detail}`;
|
|
2038
|
+
return check.hint === void 0 ? line : `${line}
|
|
2039
|
+
${" ".repeat(10)}hint: ${check.hint}`;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// src/provision/detect.ts
|
|
2043
|
+
import fs11 from "fs";
|
|
2044
|
+
function dirExists(dir) {
|
|
2045
|
+
try {
|
|
2046
|
+
return fs11.statSync(dir).isDirectory();
|
|
2047
|
+
} catch {
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
function isWritable(target) {
|
|
2052
|
+
try {
|
|
2053
|
+
fs11.accessSync(target, fs11.constants.W_OK);
|
|
2054
|
+
return true;
|
|
2055
|
+
} catch {
|
|
2056
|
+
return false;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
async function labUserExists(name, env = process.env) {
|
|
2060
|
+
try {
|
|
2061
|
+
const result = await runCommand("getent", ["passwd", "--", name], {
|
|
2062
|
+
env,
|
|
2063
|
+
timeoutMs: 1e4
|
|
2064
|
+
});
|
|
2065
|
+
if (!result.ok) {
|
|
2066
|
+
return false;
|
|
2067
|
+
}
|
|
2068
|
+
return result.stdout.split("\n").some((line) => line.split(":")[0] === name);
|
|
2069
|
+
} catch {
|
|
2070
|
+
return false;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
async function collectSnapshot(opts = {}) {
|
|
2074
|
+
const env = opts.env ?? process.env;
|
|
2075
|
+
const projectDir = opts.projectDir ?? process.cwd();
|
|
2076
|
+
let config = {
|
|
2077
|
+
ok: true,
|
|
2078
|
+
error: null,
|
|
2079
|
+
profile: null
|
|
2080
|
+
};
|
|
2081
|
+
let loaded;
|
|
2082
|
+
try {
|
|
2083
|
+
loaded = await loadConfig(projectDir, env);
|
|
2084
|
+
config = { ok: true, error: null, profile: loaded.profile ?? null };
|
|
2085
|
+
} catch (error) {
|
|
2086
|
+
loaded = { ...resolvedDefaults };
|
|
2087
|
+
config = { ok: false, error: error.message, profile: null };
|
|
2088
|
+
}
|
|
2089
|
+
const avdName = opts.avdName ?? loaded.android?.avdName ?? resolvedDefaults.android.avdName;
|
|
2090
|
+
const labUserName = opts.labUserName ?? loaded.labUser?.name ?? resolvedDefaults.labUser.name;
|
|
2091
|
+
const labUserHome = opts.labUserHome ?? loaded.labUser?.home ?? resolvedDefaults.labUser.home;
|
|
2092
|
+
const homePath = picklabHome(env);
|
|
2093
|
+
const homeExists = dirExists(homePath);
|
|
2094
|
+
const androidEnv = detectAndroidEnvironment({
|
|
2095
|
+
env,
|
|
2096
|
+
homeDir: env.HOME !== void 0 && env.HOME !== "" ? env.HOME : void 0,
|
|
2097
|
+
kvmPath: env.PICKLAB_KVM_PATH !== void 0 && env.PICKLAB_KVM_PATH !== "" ? env.PICKLAB_KVM_PATH : void 0
|
|
2098
|
+
});
|
|
2099
|
+
const avds = await listAvds({ sdk: androidEnv.sdkRoot, env });
|
|
2100
|
+
return {
|
|
2101
|
+
picklabHome: {
|
|
2102
|
+
path: homePath,
|
|
2103
|
+
exists: homeExists,
|
|
2104
|
+
writable: homeExists && isWritable(homePath)
|
|
2105
|
+
},
|
|
2106
|
+
config,
|
|
2107
|
+
desktop: {
|
|
2108
|
+
xvfb: findOnPath("Xvfb", env),
|
|
2109
|
+
xdotool: findOnPath("xdotool", env),
|
|
2110
|
+
screenshotTool: detectScreenshotTool(env),
|
|
2111
|
+
x11vnc: detectVncBinary(env)
|
|
2112
|
+
},
|
|
2113
|
+
android: {
|
|
2114
|
+
...androidEnv,
|
|
2115
|
+
avdName,
|
|
2116
|
+
avds,
|
|
2117
|
+
avdExists: avds.includes(avdName)
|
|
2118
|
+
},
|
|
2119
|
+
labUser: {
|
|
2120
|
+
name: labUserName,
|
|
2121
|
+
home: labUserHome,
|
|
2122
|
+
exists: await labUserExists(labUserName, env),
|
|
2123
|
+
homeExists: dirExists(labUserHome)
|
|
2124
|
+
},
|
|
2125
|
+
sudo: findOnPath("sudo", env)
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// src/provision/executor.ts
|
|
2130
|
+
import fs12 from "fs";
|
|
2131
|
+
|
|
2132
|
+
// src/provision/plan.ts
|
|
2133
|
+
function planHasCommandSteps(plan) {
|
|
2134
|
+
return plan.steps.some((step) => step.kind === "command");
|
|
2135
|
+
}
|
|
2136
|
+
function formatStep(step) {
|
|
2137
|
+
switch (step.kind) {
|
|
2138
|
+
case "command":
|
|
2139
|
+
return `$ ${step.command.cmd} ${step.command.args.join(" ")}`;
|
|
2140
|
+
case "mkdir":
|
|
2141
|
+
return `mkdir -p ${step.dir}`;
|
|
2142
|
+
case "write-global-config":
|
|
2143
|
+
return `update global config: ${JSON.stringify(step.config)}`;
|
|
2144
|
+
case "write-project-config":
|
|
2145
|
+
return `write project config: ${JSON.stringify(step.config)}`;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// src/provision/executor.ts
|
|
2150
|
+
var DEFAULT_STEP_TIMEOUT_MS = 18e4;
|
|
2151
|
+
async function patchGlobalConfig(patch, env = process.env) {
|
|
2152
|
+
const existing = await readConfigFile(globalConfigPath(env));
|
|
2153
|
+
await saveGlobalConfig(deepMerge(existing, patch), env);
|
|
2154
|
+
}
|
|
2155
|
+
async function patchProjectConfig(projectDir, patch) {
|
|
2156
|
+
const existing = await readConfigFile(projectConfigPath(projectDir));
|
|
2157
|
+
await saveProjectConfig(
|
|
2158
|
+
projectDir,
|
|
2159
|
+
deepMerge(existing, patch)
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
async function executeStep(step, opts) {
|
|
2163
|
+
switch (step.kind) {
|
|
2164
|
+
case "mkdir": {
|
|
2165
|
+
await fs12.promises.mkdir(step.dir, { recursive: true });
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
case "command": {
|
|
2169
|
+
const command = step.command;
|
|
2170
|
+
const result = await runCommand(command.cmd, command.args, {
|
|
2171
|
+
env: { ...opts.env, ...command.env },
|
|
2172
|
+
input: command.input,
|
|
2173
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS
|
|
2174
|
+
});
|
|
2175
|
+
if (!result.ok) {
|
|
2176
|
+
const detail = result.stderr.trim() || result.stdout.trim() || (result.timedOut ? "timed out" : `exit code ${result.code}`);
|
|
2177
|
+
throw new Error(
|
|
2178
|
+
`${command.cmd} ${command.args.join(" ")} failed: ${detail}`
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
case "write-global-config": {
|
|
2184
|
+
await patchGlobalConfig(step.config, opts.env);
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
case "write-project-config": {
|
|
2188
|
+
if (opts.projectDir === void 0) {
|
|
2189
|
+
throw new Error("write-project-config step requires a project directory");
|
|
2190
|
+
}
|
|
2191
|
+
await patchProjectConfig(opts.projectDir, step.config);
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
async function executePlan(plan, opts = {}) {
|
|
2197
|
+
const log = opts.log ?? (() => {
|
|
2198
|
+
});
|
|
2199
|
+
const results = [];
|
|
2200
|
+
for (const step of plan.steps) {
|
|
2201
|
+
if (opts.dryRun === true) {
|
|
2202
|
+
log(`[dry-run] ${step.title}: ${formatStep(step)}`);
|
|
2203
|
+
results.push({ id: step.id, ok: true, detail: "dry-run" });
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
try {
|
|
2207
|
+
await executeStep(step, opts);
|
|
2208
|
+
log(`[done] ${step.title}`);
|
|
2209
|
+
results.push({ id: step.id, ok: true, detail: formatStep(step) });
|
|
2210
|
+
} catch (error) {
|
|
2211
|
+
const message = `Step "${step.id}" failed: ${error.message}`;
|
|
2212
|
+
log(`[failed] ${step.title}: ${error.message}`);
|
|
2213
|
+
results.push({ id: step.id, ok: false, detail: message });
|
|
2214
|
+
return { ok: false, results, error: message };
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
return { ok: true, results };
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// src/provision/prompts.ts
|
|
2221
|
+
import readline from "readline/promises";
|
|
2222
|
+
async function confirm(question, opts = {}) {
|
|
2223
|
+
if (opts.yes === true) {
|
|
2224
|
+
return "yes";
|
|
2225
|
+
}
|
|
2226
|
+
const input = opts.input ?? process.stdin;
|
|
2227
|
+
const output = opts.output ?? process.stderr;
|
|
2228
|
+
if (input.isTTY !== true) {
|
|
2229
|
+
return "non-interactive";
|
|
2230
|
+
}
|
|
2231
|
+
const rl = readline.createInterface({ input, output });
|
|
2232
|
+
try {
|
|
2233
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
2234
|
+
return /^y(es)?$/i.test(answer.trim()) ? "yes" : "no";
|
|
2235
|
+
} finally {
|
|
2236
|
+
rl.close();
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
// src/commands/doctor.ts
|
|
2241
|
+
async function consentToRepair(question, opts) {
|
|
2242
|
+
if (opts.dryRun === true) {
|
|
2243
|
+
return true;
|
|
2244
|
+
}
|
|
2245
|
+
const answer = await confirm(question, { yes: opts.yes });
|
|
2246
|
+
return answer === "yes";
|
|
2247
|
+
}
|
|
2248
|
+
async function buildFixPlan(snapshot, opts) {
|
|
2249
|
+
const steps = [];
|
|
2250
|
+
const skipped = [];
|
|
2251
|
+
const consentHint = "skipped (requires consent; re-run with --yes or confirm interactively)";
|
|
2252
|
+
steps.push(
|
|
2253
|
+
...planPicklabHome({
|
|
2254
|
+
path: snapshot.picklabHome.path,
|
|
2255
|
+
exists: snapshot.picklabHome.exists
|
|
2256
|
+
}).steps
|
|
2257
|
+
);
|
|
2258
|
+
if (!snapshot.android.avdExists) {
|
|
2259
|
+
const result = planCreateAvd({
|
|
2260
|
+
avdName: snapshot.android.avdName,
|
|
2261
|
+
sdkRoot: snapshot.android.sdkRoot,
|
|
2262
|
+
avdmanagerPath: snapshot.android.tools.avdmanager,
|
|
2263
|
+
installedImages: snapshot.android.systemImages,
|
|
2264
|
+
existingAvds: snapshot.android.avds
|
|
2265
|
+
});
|
|
2266
|
+
if (!result.ok) {
|
|
2267
|
+
skipped.push(`avd: ${result.error}`);
|
|
2268
|
+
} else if (!planHasCommandSteps(result.plan) || await consentToRepair(
|
|
2269
|
+
`Create AVD "${snapshot.android.avdName}" (runs avdmanager)?`,
|
|
2270
|
+
opts
|
|
2271
|
+
)) {
|
|
2272
|
+
steps.push(...result.plan.steps);
|
|
2273
|
+
} else {
|
|
2274
|
+
skipped.push(`avd: ${consentHint}`);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
if (!snapshot.labUser.exists) {
|
|
2278
|
+
const result = planLabUser({
|
|
2279
|
+
name: snapshot.labUser.name,
|
|
2280
|
+
home: snapshot.labUser.home,
|
|
2281
|
+
userExists: snapshot.labUser.exists,
|
|
2282
|
+
homeExists: snapshot.labUser.homeExists,
|
|
2283
|
+
kvmPresent: snapshot.android.kvm.exists,
|
|
2284
|
+
sudoPath: snapshot.sudo,
|
|
2285
|
+
nonInteractive: process.stdin.isTTY !== true
|
|
2286
|
+
});
|
|
2287
|
+
if (!result.ok) {
|
|
2288
|
+
skipped.push(`lab-user: ${result.error}`);
|
|
2289
|
+
} else if (!planHasCommandSteps(result.plan) || await consentToRepair(
|
|
2290
|
+
`Create lab user "${snapshot.labUser.name}" (privileged, runs sudo)?`,
|
|
2291
|
+
opts
|
|
2292
|
+
)) {
|
|
2293
|
+
steps.push(...result.plan.steps);
|
|
2294
|
+
} else {
|
|
2295
|
+
skipped.push(`lab-user: ${consentHint}`);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
return { steps, skipped };
|
|
2299
|
+
}
|
|
2300
|
+
async function runDoctor(opts, env = process.env) {
|
|
2301
|
+
const projectDir = opts.projectDir ?? process.cwd();
|
|
2302
|
+
const snapshot = await collectSnapshot({ env, projectDir });
|
|
2303
|
+
const checks = evaluateChecks(snapshot);
|
|
2304
|
+
const report = {
|
|
2305
|
+
ok: !checks.some((check) => check.status === "missing"),
|
|
2306
|
+
checks,
|
|
2307
|
+
errors: []
|
|
2308
|
+
};
|
|
2309
|
+
if (opts.json !== true) {
|
|
2310
|
+
for (const check of checks) {
|
|
2311
|
+
console.log(formatCheckLine(check));
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
let exitCode = 0;
|
|
2315
|
+
if (opts.fix === true) {
|
|
2316
|
+
const { steps, skipped } = await buildFixPlan(snapshot, opts);
|
|
2317
|
+
const log = opts.json === true ? () => {
|
|
2318
|
+
} : (line) => console.log(line);
|
|
2319
|
+
const execution = await executePlan(
|
|
2320
|
+
{ steps },
|
|
2321
|
+
{ dryRun: opts.dryRun, env, projectDir, log }
|
|
2322
|
+
);
|
|
2323
|
+
report.fix = {
|
|
2324
|
+
dryRun: opts.dryRun === true,
|
|
2325
|
+
steps,
|
|
2326
|
+
skipped,
|
|
2327
|
+
results: execution.results
|
|
2328
|
+
};
|
|
2329
|
+
if (!execution.ok) {
|
|
2330
|
+
report.errors.push(execution.error ?? "repairs failed");
|
|
2331
|
+
exitCode = 1;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
if (opts.json === true) {
|
|
2335
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2336
|
+
} else {
|
|
2337
|
+
if (report.fix !== void 0) {
|
|
2338
|
+
for (const entry of report.fix.skipped) {
|
|
2339
|
+
console.log(`[skipped] ${entry}`);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
for (const error of report.errors) {
|
|
2343
|
+
console.error(`error: ${error}`);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
return exitCode;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// src/commands/init.ts
|
|
2350
|
+
import path10 from "path";
|
|
2351
|
+
async function consentTo(what, opts, remediation) {
|
|
2352
|
+
if (opts.yes === true || opts.dryRun === true) {
|
|
2353
|
+
return { granted: true };
|
|
2354
|
+
}
|
|
2355
|
+
const answer = await confirm(`Provision ${what}?`, {});
|
|
2356
|
+
if (answer === "yes") {
|
|
2357
|
+
return { granted: true };
|
|
2358
|
+
}
|
|
2359
|
+
if (answer === "no") {
|
|
2360
|
+
return { granted: false, error: `Required ${what} was declined. ${remediation}` };
|
|
2361
|
+
}
|
|
2362
|
+
return {
|
|
2363
|
+
granted: false,
|
|
2364
|
+
error: `Refusing to provision ${what} without consent in a non-interactive session. ${remediation}`
|
|
2365
|
+
};
|
|
2366
|
+
}
|
|
2367
|
+
async function planAvdProvisioning(snapshot, opts, steps, errors, handledCheckIds) {
|
|
2368
|
+
const result = planCreateAvd({
|
|
2369
|
+
avdName: snapshot.android.avdName,
|
|
2370
|
+
sdkRoot: snapshot.android.sdkRoot,
|
|
2371
|
+
avdmanagerPath: snapshot.android.tools.avdmanager,
|
|
2372
|
+
installedImages: snapshot.android.systemImages,
|
|
2373
|
+
existingAvds: snapshot.android.avds
|
|
2374
|
+
});
|
|
2375
|
+
if (!result.ok) {
|
|
2376
|
+
errors.push(result.error);
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
if (planHasCommandSteps(result.plan)) {
|
|
2380
|
+
const consent = await consentTo(
|
|
2381
|
+
`dedicated AVD "${snapshot.android.avdName}" (runs avdmanager)`,
|
|
2382
|
+
opts,
|
|
2383
|
+
"Re-run with --yes --create-avd or run: picklab setup android --create-avd"
|
|
2384
|
+
);
|
|
2385
|
+
if (!consent.granted) {
|
|
2386
|
+
errors.push(consent.error ?? "AVD provisioning was not approved");
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
steps.push(...result.plan.steps);
|
|
2391
|
+
handledCheckIds.add("avd");
|
|
2392
|
+
}
|
|
2393
|
+
async function planLabUserProvisioning(snapshot, opts, steps, errors, handledCheckIds) {
|
|
2394
|
+
const result = planLabUser({
|
|
2395
|
+
name: snapshot.labUser.name,
|
|
2396
|
+
home: snapshot.labUser.home,
|
|
2397
|
+
userExists: snapshot.labUser.exists,
|
|
2398
|
+
homeExists: snapshot.labUser.homeExists,
|
|
2399
|
+
kvmPresent: snapshot.android.kvm.exists,
|
|
2400
|
+
sudoPath: snapshot.sudo,
|
|
2401
|
+
nonInteractive: process.stdin.isTTY !== true
|
|
2402
|
+
});
|
|
2403
|
+
if (!result.ok) {
|
|
2404
|
+
errors.push(result.error);
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
if (planHasCommandSteps(result.plan)) {
|
|
2408
|
+
const consent = await consentTo(
|
|
2409
|
+
`lab user "${snapshot.labUser.name}" (privileged, runs sudo)`,
|
|
2410
|
+
opts,
|
|
2411
|
+
"Re-run with --yes --create-lab-user or run: picklab setup lab-user"
|
|
2412
|
+
);
|
|
2413
|
+
if (!consent.granted) {
|
|
2414
|
+
errors.push(consent.error ?? "Lab user provisioning was not approved");
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
steps.push(...result.plan.steps);
|
|
2419
|
+
handledCheckIds.add("lab-user");
|
|
2420
|
+
}
|
|
2421
|
+
async function runInit(opts, env = process.env) {
|
|
2422
|
+
const profile = opts.profile ?? "generic";
|
|
2423
|
+
const projectDir = path10.resolve(opts.projectDir ?? process.cwd());
|
|
2424
|
+
const snapshot = await collectSnapshot({ env, projectDir });
|
|
2425
|
+
const allChecks = evaluateChecks(snapshot);
|
|
2426
|
+
const requiredIds = requiredChecksForProfile(profile);
|
|
2427
|
+
const checks = allChecks.filter((check) => requiredIds.includes(check.id));
|
|
2428
|
+
if (opts.json !== true) {
|
|
2429
|
+
for (const check of checks) {
|
|
2430
|
+
console.log(formatCheckLine(check));
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
const errors = [];
|
|
2434
|
+
const handledCheckIds = /* @__PURE__ */ new Set();
|
|
2435
|
+
const steps = [
|
|
2436
|
+
{
|
|
2437
|
+
id: "project-config",
|
|
2438
|
+
title: `Write project config (profile: ${profile})`,
|
|
2439
|
+
kind: "write-project-config",
|
|
2440
|
+
privileged: false,
|
|
2441
|
+
config: { profile }
|
|
2442
|
+
}
|
|
2443
|
+
];
|
|
2444
|
+
const homePlan = planPicklabHome({
|
|
2445
|
+
path: snapshot.picklabHome.path,
|
|
2446
|
+
exists: snapshot.picklabHome.exists
|
|
2447
|
+
});
|
|
2448
|
+
if (homePlan.steps.length > 0) {
|
|
2449
|
+
steps.push(...homePlan.steps);
|
|
2450
|
+
handledCheckIds.add("picklab-home");
|
|
2451
|
+
}
|
|
2452
|
+
const avdRequired = requiredIds.includes("avd");
|
|
2453
|
+
const labUserRequired = requiredIds.includes("lab-user");
|
|
2454
|
+
if (!snapshot.android.avdExists && (avdRequired || opts.createAvd === true)) {
|
|
2455
|
+
await planAvdProvisioning(snapshot, opts, steps, errors, handledCheckIds);
|
|
2456
|
+
}
|
|
2457
|
+
if (!snapshot.labUser.exists && (labUserRequired || opts.createLabUser === true)) {
|
|
2458
|
+
await planLabUserProvisioning(
|
|
2459
|
+
snapshot,
|
|
2460
|
+
opts,
|
|
2461
|
+
steps,
|
|
2462
|
+
errors,
|
|
2463
|
+
handledCheckIds
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
for (const check of checks) {
|
|
2467
|
+
if (check.status !== "missing") continue;
|
|
2468
|
+
if (handledCheckIds.has(check.id)) continue;
|
|
2469
|
+
errors.push(
|
|
2470
|
+
`Required check "${check.id}" failed: ${check.detail}.` + (check.hint === void 0 ? "" : ` Hint: ${check.hint}`)
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
const report = {
|
|
2474
|
+
ok: errors.length === 0,
|
|
2475
|
+
profile,
|
|
2476
|
+
projectDir,
|
|
2477
|
+
dryRun: opts.dryRun === true,
|
|
2478
|
+
checks,
|
|
2479
|
+
plan: steps,
|
|
2480
|
+
results: [],
|
|
2481
|
+
errors
|
|
2482
|
+
};
|
|
2483
|
+
if (errors.length > 0) {
|
|
2484
|
+
emit(report, opts);
|
|
2485
|
+
return 1;
|
|
2486
|
+
}
|
|
2487
|
+
const log = opts.json === true ? () => {
|
|
2488
|
+
} : (line) => console.log(line);
|
|
2489
|
+
const execution = await executePlan(
|
|
2490
|
+
{ steps },
|
|
2491
|
+
{ dryRun: opts.dryRun, env, projectDir, log }
|
|
2492
|
+
);
|
|
2493
|
+
report.results = execution.results;
|
|
2494
|
+
if (!execution.ok) {
|
|
2495
|
+
report.ok = false;
|
|
2496
|
+
report.errors.push(execution.error ?? "provisioning failed");
|
|
2497
|
+
}
|
|
2498
|
+
emit(report, opts);
|
|
2499
|
+
return report.ok ? 0 : 1;
|
|
2500
|
+
}
|
|
2501
|
+
function emit(report, opts) {
|
|
2502
|
+
if (opts.json === true) {
|
|
2503
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
for (const error of report.errors) {
|
|
2507
|
+
console.error(`error: ${error}`);
|
|
2508
|
+
}
|
|
2509
|
+
if (report.ok) {
|
|
2510
|
+
console.log(
|
|
2511
|
+
report.dryRun ? `[dry-run] init complete for profile ${report.profile} (no changes made)` : `Initialized PickLab project (profile: ${report.profile}) in ${report.projectDir}`
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
// src/commands/session.ts
|
|
2517
|
+
async function createDesktopLeg(opts) {
|
|
2518
|
+
const handle = await createDesktopSession({
|
|
2519
|
+
projectDir: resolveProjectDir(opts),
|
|
2520
|
+
width: opts.width === void 0 ? void 0 : parseIntArg(opts.width, "--width"),
|
|
2521
|
+
height: opts.height === void 0 ? void 0 : parseIntArg(opts.height, "--height"),
|
|
2522
|
+
vnc: opts.vnc
|
|
2523
|
+
});
|
|
2524
|
+
const summary = {
|
|
2525
|
+
id: handle.id,
|
|
2526
|
+
type: "desktop",
|
|
2527
|
+
display: handle.display,
|
|
2528
|
+
logDir: handle.logDir
|
|
2529
|
+
};
|
|
2530
|
+
if (handle.vncPort !== void 0) {
|
|
2531
|
+
summary.vncPort = handle.vncPort;
|
|
2532
|
+
}
|
|
2533
|
+
return summary;
|
|
2534
|
+
}
|
|
2535
|
+
async function createAndroidLeg(opts) {
|
|
2536
|
+
const projectDir = resolveProjectDir(opts);
|
|
2537
|
+
const config = await loadConfig(projectDir);
|
|
2538
|
+
const avdName = opts.avdName ?? config.android?.avdName;
|
|
2539
|
+
const handle = await createAndroidSession(
|
|
2540
|
+
avdName === void 0 ? { projectDir } : { projectDir, avdName }
|
|
2541
|
+
);
|
|
2542
|
+
return {
|
|
2543
|
+
id: handle.id,
|
|
2544
|
+
type: "android",
|
|
2545
|
+
avdName: handle.avdName,
|
|
2546
|
+
serial: handle.serial,
|
|
2547
|
+
consolePort: handle.consolePort,
|
|
2548
|
+
logDir: handle.logDir
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
function describeCreated(summary) {
|
|
2552
|
+
if (summary.type === "desktop") {
|
|
2553
|
+
const vnc = summary.vncPort === void 0 ? "" : `, vnc port ${summary.vncPort}`;
|
|
2554
|
+
return `created desktop session ${summary.id} (display ${summary.display}${vnc})`;
|
|
2555
|
+
}
|
|
2556
|
+
return `created android session ${summary.id} (serial ${summary.serial})`;
|
|
2557
|
+
}
|
|
2558
|
+
async function runSessionCreate(opts) {
|
|
2559
|
+
return runReported(opts, async () => {
|
|
2560
|
+
const sessions = [];
|
|
2561
|
+
if (opts.type === "desktop" || opts.type === "desktop+android") {
|
|
2562
|
+
sessions.push(await createDesktopLeg(opts));
|
|
2563
|
+
}
|
|
2564
|
+
if (opts.type === "android" || opts.type === "desktop+android") {
|
|
2565
|
+
try {
|
|
2566
|
+
sessions.push(await createAndroidLeg(opts));
|
|
2567
|
+
} catch (error) {
|
|
2568
|
+
const desktop = sessions.find((session) => session.type === "desktop");
|
|
2569
|
+
if (desktop !== void 0) {
|
|
2570
|
+
await destroyDesktopSession(desktop.id).catch(() => {
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
throw error;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
return { data: { sessions }, lines: sessions.map(describeCreated) };
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
async function sessionStatusEntry(record) {
|
|
2580
|
+
const entry = {
|
|
2581
|
+
id: record.id,
|
|
2582
|
+
type: record.type,
|
|
2583
|
+
status: record.status,
|
|
2584
|
+
createdAt: record.createdAt,
|
|
2585
|
+
projectDir: record.projectDir
|
|
2586
|
+
};
|
|
2587
|
+
if (record.type === "desktop") {
|
|
2588
|
+
const status = await getDesktopSessionStatus(record.id);
|
|
2589
|
+
entry.desktop = {
|
|
2590
|
+
...record.desktop,
|
|
2591
|
+
xvfbAlive: status.xvfbAlive,
|
|
2592
|
+
vncAlive: status.vncAlive,
|
|
2593
|
+
displayAlive: status.displayAlive
|
|
2594
|
+
};
|
|
2595
|
+
} else if (record.type === "android") {
|
|
2596
|
+
const status = await getAndroidSessionStatus(record.id);
|
|
2597
|
+
entry.android = {
|
|
2598
|
+
...record.android,
|
|
2599
|
+
emulatorAlive: status.emulatorAlive,
|
|
2600
|
+
deviceState: status.deviceState
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
return entry;
|
|
2604
|
+
}
|
|
2605
|
+
function statusLine(entry) {
|
|
2606
|
+
const parts = [`${entry.id} ${entry.type} ${entry.status}`];
|
|
2607
|
+
const desktop = entry.desktop;
|
|
2608
|
+
if (desktop !== void 0) {
|
|
2609
|
+
parts.push(
|
|
2610
|
+
`display=${desktop.display}`,
|
|
2611
|
+
`xvfb=${desktop.xvfbAlive === true ? "alive" : "dead"}`
|
|
2612
|
+
);
|
|
2613
|
+
if (desktop.vncPort !== void 0) {
|
|
2614
|
+
parts.push(`vnc=${desktop.vncAlive === true ? "alive" : "dead"}`);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
const android = entry.android;
|
|
2618
|
+
if (android !== void 0) {
|
|
2619
|
+
parts.push(
|
|
2620
|
+
`serial=${android.serial ?? "unknown"}`,
|
|
2621
|
+
`emulator=${android.emulatorAlive === true ? "alive" : "dead"}`,
|
|
2622
|
+
`device=${android.deviceState ?? "unknown"}`
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
return parts.join(" ");
|
|
2626
|
+
}
|
|
2627
|
+
async function runSessionStatus(id, opts) {
|
|
2628
|
+
return runReported(opts, async () => {
|
|
2629
|
+
let records;
|
|
2630
|
+
if (id !== void 0) {
|
|
2631
|
+
const record = await getSession(id);
|
|
2632
|
+
if (record === void 0) {
|
|
2633
|
+
throw new Error(`Session not found: ${id}`);
|
|
2634
|
+
}
|
|
2635
|
+
records = [record];
|
|
2636
|
+
} else {
|
|
2637
|
+
records = await listSessions();
|
|
2638
|
+
}
|
|
2639
|
+
const sessions = [];
|
|
2640
|
+
for (const record of records) {
|
|
2641
|
+
sessions.push(await sessionStatusEntry(record));
|
|
2642
|
+
}
|
|
2643
|
+
return {
|
|
2644
|
+
data: { sessions },
|
|
2645
|
+
lines: sessions.length === 0 ? ["no sessions"] : sessions.map(statusLine)
|
|
2646
|
+
};
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
async function destroyRecord(record) {
|
|
2650
|
+
if (record.type === "desktop") {
|
|
2651
|
+
await destroyDesktopSession(record.id);
|
|
2652
|
+
} else if (record.type === "android") {
|
|
2653
|
+
await destroyAndroidSession(record.id);
|
|
2654
|
+
} else {
|
|
2655
|
+
throw new Error(
|
|
2656
|
+
`Cannot destroy session ${record.id} of type "${record.type}"`
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
async function runSessionDestroy(id, opts) {
|
|
2661
|
+
return runReported(opts, async () => {
|
|
2662
|
+
if (id !== void 0 && opts.all === true) {
|
|
2663
|
+
throw new Error("Pass either a session id or --all, not both");
|
|
2664
|
+
}
|
|
2665
|
+
if (id === void 0 && opts.all !== true) {
|
|
2666
|
+
throw new Error("Pass a session id or --all");
|
|
2667
|
+
}
|
|
2668
|
+
const records = [];
|
|
2669
|
+
if (id !== void 0) {
|
|
2670
|
+
const record = await getSession(id);
|
|
2671
|
+
if (record === void 0) {
|
|
2672
|
+
throw new Error(`Session not found: ${id}`);
|
|
2673
|
+
}
|
|
2674
|
+
records.push(record);
|
|
2675
|
+
} else {
|
|
2676
|
+
records.push(...await listSessions());
|
|
2677
|
+
}
|
|
2678
|
+
const destroyed = [];
|
|
2679
|
+
const errors = [];
|
|
2680
|
+
for (const record of records) {
|
|
2681
|
+
try {
|
|
2682
|
+
await destroyRecord(record);
|
|
2683
|
+
destroyed.push(record.id);
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
errors.push(
|
|
2686
|
+
`${record.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
2687
|
+
);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
return {
|
|
2691
|
+
data: { destroyed },
|
|
2692
|
+
lines: destroyed.length === 0 ? ["no sessions destroyed"] : destroyed.map((sessionId) => `destroyed session ${sessionId}`),
|
|
2693
|
+
errors
|
|
2694
|
+
};
|
|
2695
|
+
});
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// src/commands/setup-android.ts
|
|
2699
|
+
function describe(report) {
|
|
2700
|
+
const lines = [
|
|
2701
|
+
`Android SDK: ${report.sdkRoot ?? "not found"}`,
|
|
2702
|
+
`sdkmanager: ${report.tools.sdkmanager ?? "not found"}`,
|
|
2703
|
+
`avdmanager: ${report.tools.avdmanager ?? "not found"}`,
|
|
2704
|
+
`emulator: ${report.tools.emulator ?? "not found"}`,
|
|
2705
|
+
`adb: ${report.tools.adb ?? "not found"}`,
|
|
2706
|
+
`system images: ${report.systemImages.length > 0 ? report.systemImages.join(", ") : "none installed"}`,
|
|
2707
|
+
`KVM: ${report.kvm.supported ? "available" : "unavailable"}`,
|
|
2708
|
+
`AVDs: ${report.avds.length > 0 ? report.avds.join(", ") : "none"}`
|
|
2709
|
+
];
|
|
2710
|
+
if (report.systemImages.length === 0) {
|
|
2711
|
+
lines.push(
|
|
2712
|
+
`hint: install a system image with: ` + sdkmanagerInstallCommand(RECOMMENDED_SYSTEM_IMAGE)
|
|
2713
|
+
);
|
|
2714
|
+
}
|
|
2715
|
+
return lines;
|
|
2716
|
+
}
|
|
2717
|
+
function emit2(report, json) {
|
|
2718
|
+
if (json) {
|
|
2719
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
for (const line of describe(report)) {
|
|
2723
|
+
console.log(line);
|
|
2724
|
+
}
|
|
2725
|
+
for (const error of report.errors) {
|
|
2726
|
+
console.error(`error: ${error}`);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
async function runSetupAndroid(opts, env = process.env) {
|
|
2730
|
+
const snapshot = await collectSnapshot({ env, avdName: opts.avdName });
|
|
2731
|
+
const android = snapshot.android;
|
|
2732
|
+
const report = {
|
|
2733
|
+
ok: true,
|
|
2734
|
+
sdkRoot: android.sdkRoot,
|
|
2735
|
+
tools: android.tools,
|
|
2736
|
+
systemImages: android.systemImages.map((image) => image.packageId),
|
|
2737
|
+
kvm: android.kvm,
|
|
2738
|
+
avdName: android.avdName,
|
|
2739
|
+
avds: android.avds,
|
|
2740
|
+
avdExists: android.avdExists,
|
|
2741
|
+
dryRun: opts.dryRun === true,
|
|
2742
|
+
plan: [],
|
|
2743
|
+
results: [],
|
|
2744
|
+
errors: []
|
|
2745
|
+
};
|
|
2746
|
+
if (opts.createAvd !== true) {
|
|
2747
|
+
emit2(report, opts.json === true);
|
|
2748
|
+
return 0;
|
|
2749
|
+
}
|
|
2750
|
+
const result = planCreateAvd({
|
|
2751
|
+
avdName: android.avdName,
|
|
2752
|
+
systemImage: opts.systemImage,
|
|
2753
|
+
sdkRoot: android.sdkRoot,
|
|
2754
|
+
avdmanagerPath: android.tools.avdmanager,
|
|
2755
|
+
installedImages: android.systemImages,
|
|
2756
|
+
existingAvds: android.avds
|
|
2757
|
+
});
|
|
2758
|
+
if (!result.ok) {
|
|
2759
|
+
report.ok = false;
|
|
2760
|
+
report.errors.push(result.error);
|
|
2761
|
+
emit2(report, opts.json === true);
|
|
2762
|
+
return 1;
|
|
2763
|
+
}
|
|
2764
|
+
report.plan = result.plan.steps;
|
|
2765
|
+
if (planHasCommandSteps(result.plan) && opts.dryRun !== true) {
|
|
2766
|
+
const answer = await confirm(`Create AVD "${android.avdName}"?`, {
|
|
2767
|
+
yes: opts.yes
|
|
2768
|
+
});
|
|
2769
|
+
if (answer === "non-interactive") {
|
|
2770
|
+
report.ok = false;
|
|
2771
|
+
report.errors.push(
|
|
2772
|
+
"Refusing to create the AVD without consent in a non-interactive session. Re-run with --yes."
|
|
2773
|
+
);
|
|
2774
|
+
emit2(report, opts.json === true);
|
|
2775
|
+
return 1;
|
|
2776
|
+
}
|
|
2777
|
+
if (answer === "no") {
|
|
2778
|
+
report.ok = false;
|
|
2779
|
+
report.errors.push("Aborted: AVD creation was declined.");
|
|
2780
|
+
emit2(report, opts.json === true);
|
|
2781
|
+
return 1;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
const log = opts.json === true ? () => {
|
|
2785
|
+
} : (line) => console.log(line);
|
|
2786
|
+
if (opts.json !== true && android.avdExists) {
|
|
2787
|
+
console.log(`AVD "${android.avdName}" already exists.`);
|
|
2788
|
+
}
|
|
2789
|
+
const execution = await executePlan(result.plan, {
|
|
2790
|
+
dryRun: opts.dryRun,
|
|
2791
|
+
env,
|
|
2792
|
+
log
|
|
2793
|
+
});
|
|
2794
|
+
report.results = execution.results;
|
|
2795
|
+
report.ok = execution.ok;
|
|
2796
|
+
if (!execution.ok) {
|
|
2797
|
+
report.errors.push(execution.error ?? "provisioning failed");
|
|
2798
|
+
}
|
|
2799
|
+
emit2(report, opts.json === true);
|
|
2800
|
+
return execution.ok ? 0 : 1;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
// src/commands/setup-lab-user.ts
|
|
2804
|
+
function emit3(report, json) {
|
|
2805
|
+
if (json) {
|
|
2806
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
for (const error of report.errors) {
|
|
2810
|
+
console.error(`error: ${error}`);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
async function runSetupLabUser(opts, env = process.env) {
|
|
2814
|
+
const snapshot = await collectSnapshot({
|
|
2815
|
+
env,
|
|
2816
|
+
labUserName: opts.name,
|
|
2817
|
+
labUserHome: opts.home
|
|
2818
|
+
});
|
|
2819
|
+
const report = {
|
|
2820
|
+
ok: false,
|
|
2821
|
+
name: snapshot.labUser.name,
|
|
2822
|
+
home: snapshot.labUser.home,
|
|
2823
|
+
userExists: snapshot.labUser.exists,
|
|
2824
|
+
dryRun: opts.dryRun === true,
|
|
2825
|
+
plan: [],
|
|
2826
|
+
results: [],
|
|
2827
|
+
errors: []
|
|
2828
|
+
};
|
|
2829
|
+
const result = planLabUser({
|
|
2830
|
+
name: snapshot.labUser.name,
|
|
2831
|
+
home: snapshot.labUser.home,
|
|
2832
|
+
userExists: snapshot.labUser.exists,
|
|
2833
|
+
homeExists: snapshot.labUser.homeExists,
|
|
2834
|
+
kvmPresent: snapshot.android.kvm.exists,
|
|
2835
|
+
sudoPath: snapshot.sudo,
|
|
2836
|
+
nonInteractive: process.stdin.isTTY !== true
|
|
2837
|
+
});
|
|
2838
|
+
if (!result.ok) {
|
|
2839
|
+
report.errors.push(result.error);
|
|
2840
|
+
emit3(report, opts.json === true);
|
|
2841
|
+
return 1;
|
|
2842
|
+
}
|
|
2843
|
+
report.plan = result.plan.steps;
|
|
2844
|
+
if (planHasCommandSteps(result.plan) && opts.dryRun !== true) {
|
|
2845
|
+
const answer = await confirm(
|
|
2846
|
+
`Create system user "${snapshot.labUser.name}" with home ${snapshot.labUser.home} (privileged, runs sudo)?`,
|
|
2847
|
+
{ yes: opts.yes }
|
|
2848
|
+
);
|
|
2849
|
+
if (answer === "non-interactive") {
|
|
2850
|
+
report.errors.push(
|
|
2851
|
+
"Refusing to provision the lab user without consent in a non-interactive session. Re-run with --yes."
|
|
2852
|
+
);
|
|
2853
|
+
emit3(report, opts.json === true);
|
|
2854
|
+
return 1;
|
|
2855
|
+
}
|
|
2856
|
+
if (answer === "no") {
|
|
2857
|
+
report.errors.push("Aborted: lab user provisioning was declined.");
|
|
2858
|
+
emit3(report, opts.json === true);
|
|
2859
|
+
return 1;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
const log = opts.json === true ? () => {
|
|
2863
|
+
} : (line) => console.log(line);
|
|
2864
|
+
if (opts.json !== true && snapshot.labUser.exists) {
|
|
2865
|
+
console.log(`User "${snapshot.labUser.name}" already exists.`);
|
|
2866
|
+
}
|
|
2867
|
+
const execution = await executePlan(result.plan, {
|
|
2868
|
+
dryRun: opts.dryRun,
|
|
2869
|
+
env,
|
|
2870
|
+
log
|
|
2871
|
+
});
|
|
2872
|
+
report.results = execution.results;
|
|
2873
|
+
report.ok = execution.ok;
|
|
2874
|
+
if (!execution.ok) {
|
|
2875
|
+
report.errors.push(execution.error ?? "provisioning failed");
|
|
2876
|
+
}
|
|
2877
|
+
emit3(report, opts.json === true);
|
|
2878
|
+
if (opts.json !== true && execution.ok && opts.dryRun !== true) {
|
|
2879
|
+
console.log(
|
|
2880
|
+
`Lab user "${report.name}" is ready (home: ${report.home}).`
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
return execution.ok ? 0 : 1;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// src/program.ts
|
|
2887
|
+
var require2 = createRequire(import.meta.url);
|
|
2888
|
+
var { version } = require2("../package.json");
|
|
2889
|
+
var PROFILES = [
|
|
2890
|
+
"flutter-desktop",
|
|
2891
|
+
"android",
|
|
2892
|
+
"desktop+android",
|
|
2893
|
+
"generic"
|
|
2894
|
+
];
|
|
2895
|
+
var SESSION_TYPES = ["desktop", "android", "desktop+android"];
|
|
2896
|
+
function withJson(command) {
|
|
2897
|
+
return command.option("--json", "machine-readable output");
|
|
2898
|
+
}
|
|
2899
|
+
function withProjectDir(command) {
|
|
2900
|
+
return command.option(
|
|
2901
|
+
"--project-dir <dir>",
|
|
2902
|
+
"project directory (defaults to cwd)"
|
|
2903
|
+
);
|
|
2904
|
+
}
|
|
2905
|
+
function withDesktopSession(command) {
|
|
2906
|
+
return withProjectDir(
|
|
2907
|
+
command.option("--session <id>", "desktop session id")
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
function withAndroidTarget(command) {
|
|
2911
|
+
return withProjectDir(
|
|
2912
|
+
command.option("--session <id>", "android session id").option("--serial <serial>", "adb device serial")
|
|
2913
|
+
);
|
|
2914
|
+
}
|
|
2915
|
+
function buildProgram() {
|
|
2916
|
+
const program = new Command().name("picklab").description(
|
|
2917
|
+
"Native app and Android emulator automation for AI coding agents"
|
|
2918
|
+
).version(version);
|
|
2919
|
+
program.command("doctor").description("Check dependencies and dedicated lab resources").option("--json", "machine-readable output").option("--fix", "apply repairs (privileged ones need --yes or a prompt)").option("--yes", "consent to privileged repairs without prompting").option("--dry-run", "print planned repairs without executing them").option("--project-dir <dir>", "project directory for config resolution").action(async (opts) => {
|
|
2920
|
+
process.exitCode = await runDoctor(opts);
|
|
2921
|
+
});
|
|
2922
|
+
program.command("init").description("Initialize a PickLab project and provision lab resources").addOption(
|
|
2923
|
+
new Option("--profile <profile>", "project profile").choices(PROFILES)
|
|
2924
|
+
).option("--yes", "non-interactive mode; fails closed when provisioning is impossible").option("--create-lab-user", "provision the dedicated lab user").option("--create-avd", "provision the dedicated Android AVD").option("--dry-run", "print the provisioning plan without executing it").option("--json", "machine-readable output").option("--project-dir <dir>", "project directory (defaults to cwd)").action(async (opts) => {
|
|
2925
|
+
process.exitCode = await runInit(opts);
|
|
2926
|
+
});
|
|
2927
|
+
const setup = program.command("setup").description("Provision dedicated PickLab lab resources");
|
|
2928
|
+
setup.command("lab-user").description("Create the dedicated locked lab user (uses sudo)").option("--name <name>", "lab user name").option("--home <dir>", "lab user home directory").option("--yes", "do not prompt for confirmation").option("--dry-run", "print the provisioning plan without executing it").option("--json", "machine-readable output").action(async (opts) => {
|
|
2929
|
+
process.exitCode = await runSetupLabUser(opts);
|
|
2930
|
+
});
|
|
2931
|
+
setup.command("android").description("Detect the Android toolchain and create the dedicated AVD").option("--create-avd", "create the dedicated AVD").option("--avd-name <name>", "AVD name").option("--system-image <id>", 'system image id, e.g. "system-images;android-35;google_apis;x86_64"').option("--yes", "do not prompt for confirmation").option("--dry-run", "print the provisioning plan without executing it").option("--json", "machine-readable output").action(async (opts) => {
|
|
2932
|
+
process.exitCode = await runSetupAndroid(opts);
|
|
2933
|
+
});
|
|
2934
|
+
const session = program.command("session").description("Manage desktop and Android lab sessions");
|
|
2935
|
+
withJson(
|
|
2936
|
+
withProjectDir(
|
|
2937
|
+
session.command("create").description("Create a desktop (Xvfb) and/or Android emulator session").addOption(
|
|
2938
|
+
new Option("--type <type>", "session type").choices(SESSION_TYPES).makeOptionMandatory()
|
|
2939
|
+
).option("--width <pixels>", "desktop display width").option("--height <pixels>", "desktop display height").option("--vnc", "expose the desktop display over VNC").option("--avd-name <name>", "Android AVD name")
|
|
2940
|
+
)
|
|
2941
|
+
).action(async (opts) => {
|
|
2942
|
+
process.exitCode = await runSessionCreate(opts);
|
|
2943
|
+
});
|
|
2944
|
+
withJson(
|
|
2945
|
+
session.command("status").description("Show liveness for one or all sessions").argument("[id]", "session id")
|
|
2946
|
+
).action(async (id, opts) => {
|
|
2947
|
+
process.exitCode = await runSessionStatus(id, opts);
|
|
2948
|
+
});
|
|
2949
|
+
withJson(
|
|
2950
|
+
session.command("destroy").description("Destroy a session and stop its processes").argument("[id]", "session id").option("--all", "destroy all sessions")
|
|
2951
|
+
).action(async (id, opts) => {
|
|
2952
|
+
process.exitCode = await runSessionDestroy(id, opts);
|
|
2953
|
+
});
|
|
2954
|
+
const desktop = program.command("desktop").description("Drive the desktop (X11) lab session");
|
|
2955
|
+
withJson(
|
|
2956
|
+
withDesktopSession(
|
|
2957
|
+
desktop.command("launch").description("Launch an app inside the desktop session").argument("<command>", "executable to launch").argument("[args...]", "arguments for the executable (use -- before flags)").option("--cwd <dir>", "working directory for the app").option(
|
|
2958
|
+
"--wait-window <pattern>",
|
|
2959
|
+
"wait for a window whose name contains the pattern"
|
|
2960
|
+
)
|
|
2961
|
+
)
|
|
2962
|
+
).action(async (command, args, opts) => {
|
|
2963
|
+
process.exitCode = await runDesktopLaunch(command, args, opts);
|
|
2964
|
+
});
|
|
2965
|
+
withJson(
|
|
2966
|
+
withDesktopSession(
|
|
2967
|
+
desktop.command("screenshot").description("Capture the desktop display into a run (or --out path)").option("--out <path>", "write to an explicit path instead of a run").option("--run <slug>", "run slug (default: desktop)")
|
|
2968
|
+
)
|
|
2969
|
+
).action(async (opts) => {
|
|
2970
|
+
process.exitCode = await runDesktopScreenshot(opts);
|
|
2971
|
+
});
|
|
2972
|
+
withJson(
|
|
2973
|
+
withDesktopSession(
|
|
2974
|
+
desktop.command("click").description("Click at the given coordinates").argument("<x>", "x coordinate").argument("<y>", "y coordinate").option("--button <n>", "mouse button (1-9, default 1)")
|
|
2975
|
+
)
|
|
2976
|
+
).action(async (x, y, opts) => {
|
|
2977
|
+
process.exitCode = await runDesktopClick(x, y, opts);
|
|
2978
|
+
});
|
|
2979
|
+
withJson(
|
|
2980
|
+
withDesktopSession(
|
|
2981
|
+
desktop.command("type").description("Type text into the focused window").argument("<text>", "text to type")
|
|
2982
|
+
)
|
|
2983
|
+
).action(async (text, opts) => {
|
|
2984
|
+
process.exitCode = await runDesktopType(text, opts);
|
|
2985
|
+
});
|
|
2986
|
+
withJson(
|
|
2987
|
+
withDesktopSession(
|
|
2988
|
+
desktop.command("key").description('Press a key or chord (e.g. "Return", "ctrl+s")').argument("<keys>", "key or chord to press")
|
|
2989
|
+
)
|
|
2990
|
+
).action(async (keys, opts) => {
|
|
2991
|
+
process.exitCode = await runDesktopKey(keys, opts);
|
|
2992
|
+
});
|
|
2993
|
+
const android = program.command("android").description("Drive the Android emulator lab session");
|
|
2994
|
+
withJson(
|
|
2995
|
+
withProjectDir(
|
|
2996
|
+
android.command("start").description("Start an Android emulator session (alias for session create)").option("--avd-name <name>", "Android AVD name")
|
|
2997
|
+
)
|
|
2998
|
+
).action(async (opts) => {
|
|
2999
|
+
process.exitCode = await runSessionCreate({ ...opts, type: "android" });
|
|
3000
|
+
});
|
|
3001
|
+
withJson(
|
|
3002
|
+
withAndroidTarget(
|
|
3003
|
+
android.command("install-apk").description("Install an APK on the device").argument("<apk>", "path to the APK")
|
|
3004
|
+
)
|
|
3005
|
+
).action(async (apk, opts) => {
|
|
3006
|
+
process.exitCode = await runAndroidInstallApk(apk, opts);
|
|
3007
|
+
});
|
|
3008
|
+
withJson(
|
|
3009
|
+
withAndroidTarget(
|
|
3010
|
+
android.command("launch-app").description("Launch an app by package name").argument("<package>", "application package name").option("--activity <activity>", 'activity to start (e.g. ".MainActivity")')
|
|
3011
|
+
)
|
|
3012
|
+
).action(async (packageName, opts) => {
|
|
3013
|
+
process.exitCode = await runAndroidLaunchApp(packageName, opts);
|
|
3014
|
+
});
|
|
3015
|
+
withJson(
|
|
3016
|
+
withAndroidTarget(
|
|
3017
|
+
android.command("screenshot").description("Capture the device screen into a run (or --out path)").option("--out <path>", "write to an explicit path instead of a run").option("--run <slug>", "run slug (default: android)")
|
|
3018
|
+
)
|
|
3019
|
+
).action(async (opts) => {
|
|
3020
|
+
process.exitCode = await runAndroidScreenshot(opts);
|
|
3021
|
+
});
|
|
3022
|
+
withJson(
|
|
3023
|
+
withAndroidTarget(
|
|
3024
|
+
android.command("tap").description("Tap at the given coordinates").argument("<x>", "x coordinate").argument("<y>", "y coordinate")
|
|
3025
|
+
)
|
|
3026
|
+
).action(async (x, y, opts) => {
|
|
3027
|
+
process.exitCode = await runAndroidTap(x, y, opts);
|
|
3028
|
+
});
|
|
3029
|
+
withJson(
|
|
3030
|
+
withAndroidTarget(
|
|
3031
|
+
android.command("type").description("Type text into the focused field").argument("<text>", "text to type")
|
|
3032
|
+
)
|
|
3033
|
+
).action(async (text, opts) => {
|
|
3034
|
+
process.exitCode = await runAndroidType(text, opts);
|
|
3035
|
+
});
|
|
3036
|
+
withJson(
|
|
3037
|
+
withAndroidTarget(
|
|
3038
|
+
android.command("back").description("Press the back button")
|
|
3039
|
+
)
|
|
3040
|
+
).action(async (opts) => {
|
|
3041
|
+
process.exitCode = await runAndroidBack(opts);
|
|
3042
|
+
});
|
|
3043
|
+
withJson(
|
|
3044
|
+
withAndroidTarget(
|
|
3045
|
+
android.command("home").description("Press the home button")
|
|
3046
|
+
)
|
|
3047
|
+
).action(async (opts) => {
|
|
3048
|
+
process.exitCode = await runAndroidHome(opts);
|
|
3049
|
+
});
|
|
3050
|
+
withJson(
|
|
3051
|
+
withAndroidTarget(
|
|
3052
|
+
android.command("ui-tree").description("Dump the UI hierarchy as XML").option("--out <path>", "write the XML to a file instead of stdout")
|
|
3053
|
+
)
|
|
3054
|
+
).action(async (opts) => {
|
|
3055
|
+
process.exitCode = await runAndroidUiTree(opts);
|
|
3056
|
+
});
|
|
3057
|
+
withJson(
|
|
3058
|
+
withAndroidTarget(
|
|
3059
|
+
android.command("logcat").description("Dump (or --clear) the device log with secrets redacted").option("--lines <n>", "number of recent lines (default 500)").option("--filter <spec>", 'logcat filter spec, e.g. "ActivityManager:I *:S"').option("--clear", "clear the log buffer instead of dumping it")
|
|
3060
|
+
)
|
|
3061
|
+
).action(async (opts) => {
|
|
3062
|
+
process.exitCode = await runAndroidLogcat(opts);
|
|
3063
|
+
});
|
|
3064
|
+
withJson(
|
|
3065
|
+
withAndroidTarget(
|
|
3066
|
+
android.command("adb").description(
|
|
3067
|
+
"Run a raw adb command (put adb flags after --); output is not redacted"
|
|
3068
|
+
).argument("[args...]", "adb arguments")
|
|
3069
|
+
)
|
|
3070
|
+
).action(async (args, opts) => {
|
|
3071
|
+
process.exitCode = await runAndroidAdb(args, opts);
|
|
3072
|
+
});
|
|
3073
|
+
const artifacts = program.command("artifacts").description("Inspect run artifacts recorded under .picklab/runs");
|
|
3074
|
+
withJson(
|
|
3075
|
+
withProjectDir(
|
|
3076
|
+
artifacts.command("list").description("List recorded runs")
|
|
3077
|
+
)
|
|
3078
|
+
).action(async (opts) => {
|
|
3079
|
+
process.exitCode = await runArtifactsList(opts);
|
|
3080
|
+
});
|
|
3081
|
+
withJson(
|
|
3082
|
+
withProjectDir(
|
|
3083
|
+
artifacts.command("open").description("Print (and open, when a display is available) a run directory").argument("<runId>", "run id")
|
|
3084
|
+
)
|
|
3085
|
+
).action(async (runId, opts) => {
|
|
3086
|
+
process.exitCode = await runArtifactsOpen(runId, opts);
|
|
3087
|
+
});
|
|
3088
|
+
withJson(
|
|
3089
|
+
withProjectDir(
|
|
3090
|
+
artifacts.command("report").description("Render a report for a run (default: latest)").argument("[runId]", "run id")
|
|
3091
|
+
)
|
|
3092
|
+
).action(async (runId, opts) => {
|
|
3093
|
+
process.exitCode = await runArtifactsReport(runId, opts);
|
|
3094
|
+
});
|
|
3095
|
+
const agents = program.command("agents").description("Register the PickLab MCP server with coding agents");
|
|
3096
|
+
const collectConfigPath = (value, previous) => [
|
|
3097
|
+
...previous,
|
|
3098
|
+
value
|
|
3099
|
+
];
|
|
3100
|
+
withJson(
|
|
3101
|
+
agents.command("list").description("List known agents and their registration status").option(
|
|
3102
|
+
"--config-path <agent>=<path>",
|
|
3103
|
+
"agent config file override, repeatable (e.g. cursor=/tmp/mcp.json)",
|
|
3104
|
+
collectConfigPath,
|
|
3105
|
+
[]
|
|
3106
|
+
)
|
|
3107
|
+
).action(async (opts) => {
|
|
3108
|
+
process.exitCode = await runAgentsList(opts);
|
|
3109
|
+
});
|
|
3110
|
+
for (const [verb, description] of [
|
|
3111
|
+
["install", "Register the picklab MCP server with an agent"],
|
|
3112
|
+
["link", "Register the picklab MCP server with an agent (alias of install)"]
|
|
3113
|
+
]) {
|
|
3114
|
+
withJson(
|
|
3115
|
+
agents.command(verb).description(description).argument("<agent>", "agent name (codex, claude-code, cursor)").option(
|
|
3116
|
+
"--config-path <path>",
|
|
3117
|
+
"agent config file (overrides the default location)"
|
|
3118
|
+
)
|
|
3119
|
+
).action(async (agent, opts) => {
|
|
3120
|
+
process.exitCode = await runAgentsLink(agent, opts);
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
withJson(
|
|
3124
|
+
agents.command("unlink").description("Remove the picklab MCP server entry from an agent config").argument("<agent>", "agent name (codex, claude-code, cursor, or custom)").option(
|
|
3125
|
+
"--config-path <path>",
|
|
3126
|
+
"agent config file (overrides the default location)"
|
|
3127
|
+
)
|
|
3128
|
+
).action(async (agent, opts) => {
|
|
3129
|
+
process.exitCode = await runAgentsUnlink(agent, opts);
|
|
3130
|
+
});
|
|
3131
|
+
withJson(
|
|
3132
|
+
agents.command("doctor").description(
|
|
3133
|
+
"Check agent registrations for broken symlinks and stale config"
|
|
3134
|
+
).option(
|
|
3135
|
+
"--config-path <agent>=<path>",
|
|
3136
|
+
"agent config file override, repeatable (e.g. cursor=/tmp/mcp.json)",
|
|
3137
|
+
collectConfigPath,
|
|
3138
|
+
[]
|
|
3139
|
+
)
|
|
3140
|
+
).action(async (opts) => {
|
|
3141
|
+
process.exitCode = await runAgentsDoctorCommand(opts);
|
|
3142
|
+
});
|
|
3143
|
+
withJson(
|
|
3144
|
+
agents.command("add").description("Store a custom agent MCP config snippet under ~/.picklab/agents").requiredOption("--name <name>", "custom agent name").requiredOption(
|
|
3145
|
+
"--mcp-command <command>",
|
|
3146
|
+
'MCP server command, split on whitespace (e.g. "picklab mcp serve")'
|
|
3147
|
+
).option("--force", "overwrite an existing custom agent with the same name")
|
|
3148
|
+
).action(async (opts) => {
|
|
3149
|
+
process.exitCode = await runAgentsAdd(opts);
|
|
3150
|
+
});
|
|
3151
|
+
const mcp = program.command("mcp").description("Model Context Protocol server");
|
|
3152
|
+
mcp.command("serve").description("Serve PickLab tools over MCP (stdio)").action(async () => {
|
|
3153
|
+
process.exitCode = await runMcpServe();
|
|
3154
|
+
});
|
|
3155
|
+
return program;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// src/picklab.ts
|
|
3159
|
+
try {
|
|
3160
|
+
await buildProgram().parseAsync();
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
console.error(`error: ${error.message}`);
|
|
3163
|
+
process.exitCode = 1;
|
|
3164
|
+
}
|