pi-agent-browser-native 0.2.12 → 0.2.14

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.
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Purpose: Diagnose first-run pi-agent-browser-native setup without mutating Pi or agent-browser state.
4
+ * Responsibilities: Check upstream agent-browser PATH/version, inspect Pi settings for duplicate package/checkout sources, and print actionable remediation.
5
+ * Scope: Read-only package diagnostics only; upstream browser runtime health remains the responsibility of upstream `agent-browser doctor`.
6
+ * Usage: Run via `pi-agent-browser-doctor`, `npm exec --package pi-agent-browser-native -- pi-agent-browser-doctor`, or `npm run doctor` from this repository.
7
+ * Invariants/Assumptions: The wrapper targets CAPABILITY_BASELINE.targetVersion, does not bundle agent-browser, and must not edit Pi settings or run fixing commands.
8
+ */
9
+
10
+ import { execFile as execFileCallback } from "node:child_process";
11
+ import { access, readFile } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { dirname, resolve, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { promisify } from "node:util";
16
+
17
+ import { CAPABILITY_BASELINE, CAPABILITY_BASELINE_SOURCE } from "./agent-browser-capability-baseline.mjs";
18
+
19
+ const execFile = promisify(execFileCallback);
20
+ const PACKAGE_NAME = "pi-agent-browser-native";
21
+ const REPO_URL_FRAGMENT = "github.com/fitchmultz/pi-agent-browser-native";
22
+ const EXTENSION_ENTRYPOINT = "extensions/agent-browser/index.ts";
23
+ const EXPECTED_VERSION = CAPABILITY_BASELINE.targetVersion;
24
+ const DEFAULT_AGENT_DIR = resolve(homedir(), ".pi/agent");
25
+ const THIS_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26
+
27
+ export function normalizeAgentBrowserVersion(output) {
28
+ return String(output ?? "").trim().replace(/^agent-browser\s+/, "");
29
+ }
30
+
31
+ function printHelp() {
32
+ console.log(`pi-agent-browser-doctor
33
+
34
+ Usage:
35
+ pi-agent-browser-doctor [options]
36
+
37
+ Options:
38
+ --cwd <path> Project directory used for project Pi settings and local source detection. Defaults to process.cwd().
39
+ --agent-dir <path> Pi global agent directory. Defaults to ~/.pi/agent.
40
+ --settings <path> Additional Pi settings JSON/JSONC file to inspect. Repeatable.
41
+ --skip-source-check Only check upstream agent-browser PATH/version.
42
+ -h, --help Show help.
43
+
44
+ Checks:
45
+ 1. agent-browser is installed on PATH.
46
+ 2. agent-browser --version matches the package capability baseline.
47
+ 3. Pi settings and repo-local autoload locations do not point at multiple active pi-agent-browser-native sources.
48
+
49
+ Examples:
50
+ pi-agent-browser-doctor
51
+ npm exec --package pi-agent-browser-native -- pi-agent-browser-doctor
52
+ npm run doctor
53
+ pi-agent-browser-doctor --cwd /path/to/project --settings /tmp/pi-settings.json
54
+
55
+ Exit codes:
56
+ 0 Doctor passed.
57
+ 1 Doctor found setup failures.
58
+ 2 Usage error.
59
+ `);
60
+ }
61
+
62
+ export function parseCliArgs(argv = process.argv.slice(2)) {
63
+ const parsed = {
64
+ agentDir: undefined,
65
+ cwd: undefined,
66
+ settingsPaths: [],
67
+ showHelp: false,
68
+ skipSourceCheck: false,
69
+ };
70
+
71
+ for (let index = 0; index < argv.length; index += 1) {
72
+ const arg = argv[index];
73
+ if (arg === "-h" || arg === "--help") {
74
+ parsed.showHelp = true;
75
+ continue;
76
+ }
77
+ if (arg === "--skip-source-check") {
78
+ parsed.skipSourceCheck = true;
79
+ continue;
80
+ }
81
+ if (arg === "--cwd" || arg === "--agent-dir" || arg === "--settings") {
82
+ const value = argv[index + 1];
83
+ if (value === undefined || value.startsWith("--")) {
84
+ throw new Error(`${arg} requires a value. Run with --help for usage.`);
85
+ }
86
+ index += 1;
87
+ if (arg === "--cwd") parsed.cwd = value;
88
+ if (arg === "--agent-dir") parsed.agentDir = value;
89
+ if (arg === "--settings") parsed.settingsPaths.push(value);
90
+ continue;
91
+ }
92
+ throw new Error(`Unknown option: ${arg}. Run with --help for usage.`);
93
+ }
94
+
95
+ return parsed;
96
+ }
97
+
98
+ async function defaultRunAgentBrowser(args) {
99
+ const { stdout, stderr } = await execFile("agent-browser", args, { maxBuffer: 1024 * 1024 });
100
+ return `${stdout}${stderr}`;
101
+ }
102
+
103
+ async function defaultPathExists(path) {
104
+ try {
105
+ await access(path);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function isInsidePath(childPath, parentPath) {
113
+ const child = resolve(childPath);
114
+ const parent = resolve(parentPath);
115
+ return child === parent || child.startsWith(`${parent}${sep}`);
116
+ }
117
+
118
+ function expandUserPath(path) {
119
+ if (path === "~") return homedir();
120
+ if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
121
+ return path;
122
+ }
123
+
124
+ function isPathLikeSource(source) {
125
+ return source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.startsWith("~");
126
+ }
127
+
128
+ function sourceLooksLikeThisPackage(source, cwd) {
129
+ const text = String(source ?? "").trim();
130
+ if (text.length === 0) return false;
131
+ if (/^npm:pi-agent-browser-native(?:@|$)/.test(text)) return true;
132
+ if (text === PACKAGE_NAME) return true;
133
+ if (text.includes(REPO_URL_FRAGMENT)) return true;
134
+
135
+ if (!isPathLikeSource(text)) return false;
136
+ const resolvedSource = resolve(cwd, expandUserPath(text));
137
+ const cwdEntrypoint = resolve(cwd, EXTENSION_ENTRYPOINT);
138
+ const packageEntrypoint = resolve(THIS_PACKAGE_ROOT, EXTENSION_ENTRYPOINT);
139
+ return (
140
+ resolvedSource === cwd ||
141
+ resolvedSource === cwdEntrypoint ||
142
+ resolvedSource === packageEntrypoint ||
143
+ isInsidePath(cwdEntrypoint, resolvedSource) ||
144
+ isInsidePath(packageEntrypoint, resolvedSource)
145
+ );
146
+ }
147
+
148
+ function stripJsonComments(text) {
149
+ let result = "";
150
+ let inString = false;
151
+ let quote = "";
152
+ let escaped = false;
153
+ let inLineComment = false;
154
+ let inBlockComment = false;
155
+
156
+ for (let index = 0; index < text.length; index += 1) {
157
+ const char = text[index];
158
+ const next = text[index + 1];
159
+
160
+ if (inLineComment) {
161
+ if (char === "\n") {
162
+ inLineComment = false;
163
+ result += char;
164
+ }
165
+ continue;
166
+ }
167
+ if (inBlockComment) {
168
+ if (char === "*" && next === "/") {
169
+ inBlockComment = false;
170
+ index += 1;
171
+ }
172
+ continue;
173
+ }
174
+ if (inString) {
175
+ result += char;
176
+ if (escaped) {
177
+ escaped = false;
178
+ continue;
179
+ }
180
+ if (char === "\\") {
181
+ escaped = true;
182
+ continue;
183
+ }
184
+ if (char === quote) {
185
+ inString = false;
186
+ }
187
+ continue;
188
+ }
189
+ if (char === '"' || char === "'") {
190
+ inString = true;
191
+ quote = char;
192
+ result += char;
193
+ continue;
194
+ }
195
+ if (char === "/" && next === "/") {
196
+ inLineComment = true;
197
+ index += 1;
198
+ continue;
199
+ }
200
+ if (char === "/" && next === "*") {
201
+ inBlockComment = true;
202
+ index += 1;
203
+ continue;
204
+ }
205
+ result += char;
206
+ }
207
+ return result;
208
+ }
209
+
210
+ function parseSettingsText(text, path) {
211
+ return JSON.parse(stripJsonComments(text));
212
+ }
213
+
214
+ function arrayEntries(value) {
215
+ return Array.isArray(value) ? value.entries() : [];
216
+ }
217
+
218
+ function entrySource(entry) {
219
+ if (typeof entry === "string") return entry;
220
+ if (entry && typeof entry === "object") return entry.source ?? entry.path ?? entry.package;
221
+ return undefined;
222
+ }
223
+
224
+ function collectSettingsSources(settings, settingsPath, cwd) {
225
+ const sources = [];
226
+ for (const [index, entry] of arrayEntries(settings?.packages)) {
227
+ const source = entrySource(entry);
228
+ if (sourceLooksLikeThisPackage(source, cwd)) {
229
+ sources.push({ kind: "package", source: String(source), location: `${settingsPath} packages[${index}]` });
230
+ }
231
+ }
232
+ for (const [index, entry] of arrayEntries(settings?.extensions)) {
233
+ const source = entrySource(entry);
234
+ if (sourceLooksLikeThisPackage(source, cwd)) {
235
+ sources.push({ kind: "extension", source: String(source), location: `${settingsPath} extensions[${index}]` });
236
+ }
237
+ }
238
+ return sources;
239
+ }
240
+
241
+ function dedupe(paths) {
242
+ return [...new Set(paths.map((path) => resolve(path)))];
243
+ }
244
+
245
+ async function inspectSettingsPath({ path, cwd, readText }) {
246
+ try {
247
+ const text = await readText(path);
248
+ if (text === undefined) return { sources: [], warnings: [] };
249
+ const settings = parseSettingsText(text, path);
250
+ return { sources: collectSettingsSources(settings, path, cwd), warnings: [] };
251
+ } catch (error) {
252
+ return {
253
+ sources: [],
254
+ warnings: [`Could not inspect Pi settings ${path}: ${error instanceof Error ? error.message : String(error)}`],
255
+ };
256
+ }
257
+ }
258
+
259
+ async function collectRepoLocalSources({ cwd, pathExists }) {
260
+ const candidates = [resolve(cwd, ".pi/extensions/agent-browser.ts"), resolve(cwd, ".pi/extensions/agent-browser/index.ts")];
261
+ const sources = [];
262
+ for (const candidate of candidates) {
263
+ if (await pathExists(candidate)) {
264
+ sources.push({ kind: "repo-local", source: candidate, location: `${candidate} repo-local autoload` });
265
+ }
266
+ }
267
+ return sources;
268
+ }
269
+
270
+ async function checkAgentBrowserVersion({ runAgentBrowser }) {
271
+ try {
272
+ const rawOutput = await runAgentBrowser(["--version"]);
273
+ const version = normalizeAgentBrowserVersion(rawOutput);
274
+ if (version !== EXPECTED_VERSION) {
275
+ return {
276
+ status: "fail",
277
+ title: `agent-browser version drift: expected ${EXPECTED_VERSION}, found ${version || "<empty>"}.`,
278
+ lines: [
279
+ `This wrapper targets the current baseline from ${CAPABILITY_BASELINE_SOURCE} and does not provide backwards-compatibility shims.`,
280
+ `Update upstream agent-browser to ${EXPECTED_VERSION}, or if you intentionally re-baselined upstream, update ${CAPABILITY_BASELINE_SOURCE} and refresh docs.`,
281
+ ],
282
+ };
283
+ }
284
+ return { status: "pass", title: `agent-browser version matches baseline: ${version}`, lines: [] };
285
+ } catch (error) {
286
+ const code = error && typeof error === "object" ? error.code : undefined;
287
+ return {
288
+ status: "fail",
289
+ title: "agent-browser is required but was not found on PATH.",
290
+ lines: [
291
+ "This package does not bundle agent-browser.",
292
+ "Install upstream agent-browser, then make sure `agent-browser --version` works in the same shell that launches pi.",
293
+ "Upstream docs:",
294
+ "- https://agent-browser.dev/",
295
+ "- https://github.com/vercel-labs/agent-browser",
296
+ code && code !== "ENOENT" ? `Spawn error: ${String(code)}` : undefined,
297
+ ].filter(Boolean),
298
+ };
299
+ }
300
+ }
301
+
302
+ async function checkPiSources({ cwd, agentDir, settingsPaths, readText, pathExists }) {
303
+ const defaultSettingsPaths = [resolve(agentDir, "settings.json"), resolve(cwd, ".pi/settings.json")];
304
+ const allSettingsPaths = dedupe([...defaultSettingsPaths, ...settingsPaths]);
305
+ const sources = [];
306
+ const warnings = [];
307
+
308
+ for (const path of allSettingsPaths) {
309
+ if (await pathExists(path)) {
310
+ const result = await inspectSettingsPath({ path, cwd, readText });
311
+ sources.push(...result.sources);
312
+ warnings.push(...result.warnings);
313
+ }
314
+ }
315
+ sources.push(...(await collectRepoLocalSources({ cwd, pathExists })));
316
+
317
+ if (sources.length > 1) {
318
+ return {
319
+ status: "fail",
320
+ title: "Duplicate pi-agent-browser-native sources detected.",
321
+ lines: [
322
+ "Pi may register multiple `agent_browser` tools when a checkout source and a package source are both active.",
323
+ "Detected sources:",
324
+ ...sources.map((source) => `- ${source.source} from ${source.location}`),
325
+ "Keep exactly one active source:",
326
+ "- for normal use: keep `pi install npm:pi-agent-browser-native` and remove/disable checkout paths from Pi settings",
327
+ "- for temporary package or checkout trials: use `pi --no-extensions -e <source>` so configured sources are bypassed",
328
+ "- for configured-source lifecycle validation: keep exactly one checkout or package source, then launch plain `pi`",
329
+ ],
330
+ warnings,
331
+ };
332
+ }
333
+ if (sources.length === 1) {
334
+ return {
335
+ status: "pass",
336
+ title: "No duplicate pi-agent-browser-native sources detected.",
337
+ lines: [`Detected source: ${sources[0].source} from ${sources[0].location}`],
338
+ warnings,
339
+ };
340
+ }
341
+ return {
342
+ status: "warn",
343
+ title: "No configured pi-agent-browser-native source was found in inspected Pi settings.",
344
+ lines: [
345
+ "This is OK for isolated runs such as `pi --no-extensions -e npm:pi-agent-browser-native`, but normal package use should install exactly one source with `pi install npm:pi-agent-browser-native`.",
346
+ ],
347
+ warnings,
348
+ };
349
+ }
350
+
351
+ export async function evaluateDoctor(options = {}) {
352
+ const cwd = resolve(options.cwd ?? process.cwd());
353
+ const agentDir = resolve(options.agentDir ?? DEFAULT_AGENT_DIR);
354
+ const settingsPaths = (options.settingsPaths ?? []).map((path) => resolve(cwd, path));
355
+ const readText = options.readText ?? ((path) => readFile(path, "utf8"));
356
+ const pathExists = options.pathExists ?? defaultPathExists;
357
+ const runAgentBrowser = options.runAgentBrowser ?? defaultRunAgentBrowser;
358
+ const checks = [];
359
+ const failures = [];
360
+ const warnings = [];
361
+
362
+ const versionCheck = await checkAgentBrowserVersion({ runAgentBrowser });
363
+ checks.push(versionCheck);
364
+ if (versionCheck.status === "fail") failures.push(versionCheck);
365
+
366
+ if (!options.skipSourceCheck) {
367
+ const sourceCheck = await checkPiSources({ cwd, agentDir, settingsPaths, readText, pathExists });
368
+ checks.push(sourceCheck);
369
+ if (sourceCheck.status === "fail") failures.push(sourceCheck);
370
+ warnings.push(...(sourceCheck.warnings ?? []));
371
+ }
372
+
373
+ return { checks, failures, warnings };
374
+ }
375
+
376
+ export function formatDoctorReport(report) {
377
+ const lines = ["pi-agent-browser-native doctor", ""];
378
+ for (const check of report.checks) {
379
+ const prefix = check.status === "pass" ? "✓" : check.status === "warn" ? "!" : "✗";
380
+ lines.push(`${prefix} ${check.title}`);
381
+ for (const line of check.lines ?? []) {
382
+ lines.push(` ${line}`);
383
+ }
384
+ lines.push("");
385
+ }
386
+ for (const warning of report.warnings ?? []) {
387
+ lines.push(`! ${warning}`);
388
+ }
389
+ if ((report.warnings ?? []).length > 0) lines.push("");
390
+ lines.push(report.failures.length > 0 ? "Doctor found setup failures." : "Doctor passed.");
391
+ return lines.join("\n");
392
+ }
393
+
394
+ export async function main(argv = process.argv.slice(2)) {
395
+ let args;
396
+ try {
397
+ args = parseCliArgs(argv);
398
+ } catch (error) {
399
+ console.error(error instanceof Error ? error.message : String(error));
400
+ return 2;
401
+ }
402
+ if (args.showHelp) {
403
+ printHelp();
404
+ return 0;
405
+ }
406
+ const report = await evaluateDoctor(args);
407
+ const output = formatDoctorReport(report);
408
+ if (report.failures.length > 0) {
409
+ console.error(output);
410
+ return 1;
411
+ }
412
+ console.log(output);
413
+ return 0;
414
+ }
415
+
416
+ if (import.meta.url === `file://${process.argv[1]}`) {
417
+ main().then((exitCode) => {
418
+ process.exitCode = exitCode;
419
+ });
420
+ }