portable-agent-layer 0.85.9 → 0.85.11

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,129 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, resolve } from "node:path";
4
+ import { palPkg } from "../../hooks/lib/paths";
5
+ import { findBinaryOnPath } from "../../hooks/lib/which";
6
+ import type { ToolCheck } from "./agents";
7
+ import { type Finding, type Fix, failing, optional, passed, warning } from "./finding";
8
+ import { osReleaseField, readOsRelease } from "./os-release";
9
+ import { versionControlFindings } from "./version-control";
10
+
11
+ interface PlaywrightBuild {
12
+ version: string;
13
+ revision: string;
14
+ }
15
+
16
+ interface BrowserHost {
17
+ browsersPath: string;
18
+ playwright: PlaywrightBuild | null;
19
+ platform: NodeJS.Platform;
20
+ arch: string;
21
+ osRelease: string;
22
+ }
23
+
24
+ const NEWEST_UBUNTU_PLAYWRIGHT_KNOWS = 24.04;
25
+
26
+ function playwrightBrowsersPath(): string {
27
+ if (process.env.PLAYWRIGHT_BROWSERS_PATH) return process.env.PLAYWRIGHT_BROWSERS_PATH;
28
+ const home = homedir();
29
+ if (process.platform === "darwin") return resolve(home, "Library/Caches/ms-playwright");
30
+ if (process.platform === "win32") return resolve(home, "AppData/Local/ms-playwright");
31
+ return resolve(home, ".cache/ms-playwright");
32
+ }
33
+
34
+ function installedPlaywright(): PlaywrightBuild | null {
35
+ try {
36
+ const pkg = Bun.resolveSync("playwright-core/package.json", palPkg());
37
+ const { version } = JSON.parse(readFileSync(pkg, "utf-8"));
38
+ const { browsers } = JSON.parse(
39
+ readFileSync(resolve(dirname(pkg), "browsers.json"), "utf-8")
40
+ );
41
+ const shell = (browsers as { name: string; revision: string }[]).find(
42
+ (b) => b.name === "chromium-headless-shell"
43
+ );
44
+ return shell ? { version, revision: shell.revision } : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function hasChromium(host: BrowserHost): boolean {
51
+ if (!existsSync(host.browsersPath)) return false;
52
+ const dirs = readdirSync(host.browsersPath);
53
+ const revision = host.playwright?.revision;
54
+ return revision
55
+ ? dirs.some(
56
+ (d) => d === `chromium_headless_shell-${revision}` || d === `chromium-${revision}`
57
+ )
58
+ : dirs.some((d) => d.startsWith("chromium"));
59
+ }
60
+
61
+ function playwrightPlatformOverride(host: BrowserHost): string {
62
+ if (host.platform !== "linux") return "";
63
+ if (osReleaseField(host.osRelease, "ID") !== "ubuntu") return "";
64
+ const version = Number.parseFloat(osReleaseField(host.osRelease, "VERSION_ID"));
65
+ if (!(version > NEWEST_UBUNTU_PLAYWRIGHT_KNOWS)) return "";
66
+ return `PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-${host.arch === "arm64" ? "arm64" : "x64"} `;
67
+ }
68
+
69
+ export function playwrightFinding(host: BrowserHost): Finding {
70
+ if (hasChromium(host)) return passed("playwright", "Playwright Chromium installed");
71
+ const pinned = host.playwright ? `playwright@${host.playwright.version}` : "playwright";
72
+ const override = playwrightPlatformOverride(host);
73
+ return warning(
74
+ "playwright.missing",
75
+ "Playwright's Chromium is not installed — create-pdf, consulting-report and screenshots fail",
76
+ {
77
+ say: override
78
+ ? "Install it as Ubuntu 24.04, which Playwright supports"
79
+ : "Install it",
80
+ command: `${override}bun x ${pinned} install chromium`,
81
+ }
82
+ );
83
+ }
84
+
85
+ function palOnPathFinding(): Finding {
86
+ const pal = findBinaryOnPath("pal");
87
+ return pal
88
+ ? passed("pal.path", `pal on PATH — ${pal}`)
89
+ : failing("pal.path", "pal is not on PATH — skills that run 'pal cli …' fail", {
90
+ say: "Install PAL globally",
91
+ command: "bun add -g portable-agent-layer",
92
+ });
93
+ }
94
+
95
+ function rtkInstall(): Fix {
96
+ if (process.platform === "win32")
97
+ return {
98
+ say: "Download rtk.exe from https://github.com/rtk-ai/rtk/releases and add it to PATH",
99
+ };
100
+ if (process.platform === "darwin")
101
+ return { say: "Install rtk", command: "brew install rtk" };
102
+ return {
103
+ say: "Install rtk",
104
+ command:
105
+ "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh",
106
+ };
107
+ }
108
+
109
+ function rtkFinding(rtk: ToolCheck): Finding {
110
+ return rtk.available
111
+ ? passed("rtk", rtk.version || "rtk")
112
+ : optional("rtk.missing", "rtk — compresses long command output", rtkInstall());
113
+ }
114
+
115
+ export function environmentFindings(rtk: ToolCheck): Finding[] {
116
+ return [
117
+ passed("bun", `Bun ${Bun.version}`),
118
+ palOnPathFinding(),
119
+ playwrightFinding({
120
+ browsersPath: playwrightBrowsersPath(),
121
+ playwright: installedPlaywright(),
122
+ platform: process.platform,
123
+ arch: process.arch,
124
+ osRelease: readOsRelease(),
125
+ }),
126
+ rtkFinding(rtk),
127
+ ...versionControlFindings(),
128
+ ];
129
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * One thing the doctor looked at. A problem carries the fix as a sentence and,
3
+ * where one exists, the exact command — so a later `--fix` can run it and an
4
+ * agent reading `--json` can propose it.
5
+ */
6
+
7
+ export type Severity = "fail" | "warn" | "optional" | "ok";
8
+
9
+ export interface Fix {
10
+ say: string;
11
+ command?: string;
12
+ }
13
+
14
+ export interface Finding {
15
+ id: string;
16
+ severity: Severity;
17
+ title: string;
18
+ fix?: Fix;
19
+ }
20
+
21
+ export function passed(id: string, title: string): Finding {
22
+ return { id, severity: "ok", title };
23
+ }
24
+
25
+ export function failing(id: string, title: string, fix?: Fix): Finding {
26
+ return { id, severity: "fail", title, fix };
27
+ }
28
+
29
+ export function warning(id: string, title: string, fix?: Fix): Finding {
30
+ return { id, severity: "warn", title, fix };
31
+ }
32
+
33
+ export function optional(id: string, title: string, fix: Fix): Finding {
34
+ return { id, severity: "optional", title, fix };
35
+ }
@@ -0,0 +1,54 @@
1
+ import { resolve } from "node:path";
2
+ import { cachedStatus, type UpdateCache } from "../../hooks/handlers/update-check";
3
+ import { type HookErrorGroup, recentHookErrors } from "../../hooks/lib/log";
4
+ import { palHome } from "../../hooks/lib/paths";
5
+ import { checkPendingMigrations } from "../migrate";
6
+ import { type Finding, passed, warning } from "./finding";
7
+
8
+ interface PendingMigration {
9
+ id: string;
10
+ description: string;
11
+ detail?: string;
12
+ }
13
+
14
+ export function hookErrorFindings(groups: HookErrorGroup[]): Finding[] {
15
+ if (groups.length === 0)
16
+ return [passed("hook-errors", "No hook errors in the last 24h")];
17
+ const log = resolve(palHome(), "debug", "debug.log");
18
+ return groups.map((group) => {
19
+ const times = group.count === 1 ? "once" : `${group.count} times`;
20
+ return warning(
21
+ `hook-errors.${group.source}`,
22
+ `${group.source} failed ${times} in the last 24h — last: ${group.last}`,
23
+ { say: `Full trace in ${log}` }
24
+ );
25
+ });
26
+ }
27
+
28
+ export function migrationFindings(pending: PendingMigration[]): Finding[] {
29
+ if (pending.length === 0) return [passed("migrations", "No pending migrations")];
30
+ return pending.map((migration) => {
31
+ const detail = migration.detail ? ` (${migration.detail})` : "";
32
+ return warning(
33
+ `migration.${migration.id}`,
34
+ `Migration pending: ${migration.description}${detail}`,
35
+ { say: "Run it", command: "pal cli migrate" }
36
+ );
37
+ });
38
+ }
39
+
40
+ export function updateFinding(cache: UpdateCache | null): Finding {
41
+ if (!cache?.available) return passed("update", "PAL is up to date");
42
+ return warning("update", `PAL ${cache.latest} is out (${cache.current} installed)`, {
43
+ say: "Update",
44
+ command: "pal cli update",
45
+ });
46
+ }
47
+
48
+ export function healthFindings(): Finding[] {
49
+ return [
50
+ ...hookErrorFindings(recentHookErrors()),
51
+ ...migrationFindings(checkPendingMigrations()),
52
+ updateFinding(cachedStatus()),
53
+ ];
54
+ }
@@ -0,0 +1,91 @@
1
+ import { resolve } from "node:path";
2
+ import { previewInferenceRoute } from "../../hooks/lib/inference";
3
+ import { opencodeBackgroundModel } from "../../hooks/lib/opencode-config";
4
+ import { platform } from "../../hooks/lib/paths";
5
+ import type { AgentName } from "./agents";
6
+ import { type Finding, failing, optional, passed, warning } from "./finding";
7
+
8
+ type Env = Record<string, string | undefined>;
9
+
10
+ const SUBPROCESS_ONLY = {
11
+ PAL_SPAWNED_INFERENCE: "every inference call refuses",
12
+ PAL_INFERENCE_DEPTH: "the depth breaker fires on the first call",
13
+ PAL_INFERENCE_DISABLED: "every inference call fails",
14
+ } as const;
15
+
16
+ const API_KEYS = {
17
+ PAL_ANTHROPIC_API_KEY: "fallback when the claude CLI cannot answer",
18
+ PAL_OPENAI_API_KEY: "fallback when the codex CLI cannot answer",
19
+ PAL_GEMINI_API_KEY: "YouTube analysis and Gemini research",
20
+ PAL_XAI_API_KEY: "the Grok researcher",
21
+ PAL_PERPLEXITY_API_KEY: "the Perplexity researcher",
22
+ } as const;
23
+
24
+ function unsetCommand(name: string, os: NodeJS.Platform): string {
25
+ return os === "win32" ? `Remove-Item Env:${name}` : `unset ${name}`;
26
+ }
27
+
28
+ export function leakedEnvFindings(env: Env, os: NodeJS.Platform): Finding[] {
29
+ return Object.entries(SUBPROCESS_ONLY).map(([name, effect]) =>
30
+ env[name]
31
+ ? failing(
32
+ `env.${name}`,
33
+ `${name}=${env[name]} leaked into your shell — ${effect}`,
34
+ {
35
+ say: "Clear it, and remove it from your shell profile if it is set there",
36
+ command: unsetCommand(name, os),
37
+ }
38
+ )
39
+ : passed(`env.${name}`, `${name} not set`)
40
+ );
41
+ }
42
+
43
+ export function apiKeyFindings(env: Env): Finding[] {
44
+ return Object.entries(API_KEYS).map(([name, unlocks]) =>
45
+ env[name]
46
+ ? passed(`key.${name}`, `${name} set`)
47
+ : optional(`key.${name}`, `${name} — ${unlocks}`, {
48
+ say: "set it in your shell profile",
49
+ })
50
+ );
51
+ }
52
+
53
+ function routeFinding(): Finding | null {
54
+ const preview = previewInferenceRoute();
55
+ if (preview.route === "disabled") return null;
56
+ if (preview.route === "none")
57
+ return failing(
58
+ "inference.none",
59
+ `Background inference has no route for ${preview.agent} — ${preview.reason}`,
60
+ { say: "Install the agent's CLI, or set an API key PAL can fall back to" }
61
+ );
62
+ if (preview.route.endsWith("-api"))
63
+ return warning(
64
+ "inference.api",
65
+ `Background inference goes through ${preview.route} (${preview.reason}) — it bills the API key`,
66
+ { say: "Put the agent's CLI on PATH to use your subscription instead" }
67
+ );
68
+ return passed("inference", `Inference: ${preview.route} (${preview.reason})`);
69
+ }
70
+
71
+ function opencodeModelFinding(): Finding {
72
+ return opencodeBackgroundModel()
73
+ ? passed("opencode.model", "opencode background model pinned")
74
+ : warning(
75
+ "opencode.model",
76
+ "opencode background model is not pinned — background inference uses whatever the TUI last picked",
77
+ {
78
+ say: `Pin one with "model" in ${resolve(platform.opencodeDir(), "config.json")}`,
79
+ }
80
+ );
81
+ }
82
+
83
+ export function inferenceFindings(agents: AgentName[]): Finding[] {
84
+ const route = routeFinding();
85
+ return [
86
+ ...(route ? [route] : []),
87
+ ...(agents.includes("opencode") ? [opencodeModelFinding()] : []),
88
+ ...leakedEnvFindings(process.env, process.platform),
89
+ ...apiKeyFindings(process.env),
90
+ ];
91
+ }
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export type LinuxFamily = "debian" | "fedora" | "arch" | "alpine" | "suse";
4
+
5
+ export function osReleaseField(osRelease: string, key: string): string {
6
+ return osRelease.match(new RegExp(String.raw`^${key}="?([^"\n]*)"?$`, "m"))?.[1] ?? "";
7
+ }
8
+
9
+ export function linuxFamily(osRelease: string): LinuxFamily | null {
10
+ const ids = `${osReleaseField(osRelease, "ID")} ${osReleaseField(osRelease, "ID_LIKE")}`
11
+ .split(/\s+/)
12
+ .filter(Boolean);
13
+ const families: [LinuxFamily, string[]][] = [
14
+ ["debian", ["debian", "ubuntu"]],
15
+ ["fedora", ["fedora", "rhel", "centos"]],
16
+ ["arch", ["arch"]],
17
+ ["alpine", ["alpine"]],
18
+ ["suse", ["suse", "opensuse"]],
19
+ ];
20
+ return families.find(([, names]) => names.some((n) => ids.includes(n)))?.[0] ?? null;
21
+ }
22
+
23
+ export function readOsRelease(): string {
24
+ if (process.platform !== "linux") return "";
25
+ try {
26
+ return readFileSync("/etc/os-release", "utf-8");
27
+ } catch {
28
+ return "";
29
+ }
30
+ }
@@ -0,0 +1,82 @@
1
+ import type { Finding, Fix, Severity } from "./finding";
2
+
3
+ export interface DoctorReport {
4
+ version: string;
5
+ agents: string[];
6
+ findings: Finding[];
7
+ }
8
+
9
+ interface RenderOptions {
10
+ verbose: boolean;
11
+ color: boolean;
12
+ }
13
+
14
+ const MARK: Record<Severity, { glyph: string; ansi: string }> = {
15
+ fail: { glyph: "✗", ansi: "\x1b[31m" },
16
+ warn: { glyph: "⚠", ansi: "\x1b[33m" },
17
+ optional: { glyph: "·", ansi: "\x1b[90m" },
18
+ ok: { glyph: "✓", ansi: "\x1b[32m" },
19
+ };
20
+
21
+ function plural(count: number, noun: string): string {
22
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
23
+ }
24
+
25
+ function fixText(fix: Fix): string {
26
+ return fix.command ? `${fix.say}: ${fix.command}` : fix.say;
27
+ }
28
+
29
+ function headline(report: DoctorReport, passedCount: number): string {
30
+ const agents = report.agents.length > 0 ? report.agents.join(", ") : "no agent";
31
+ return `PAL ${report.version} · ${agents} · ${plural(passedCount, "check")} passed`;
32
+ }
33
+
34
+ function summary(failCount: number, warnCount: number, passedCount: number): string {
35
+ return [
36
+ failCount > 0 ? `${failCount} failing` : "",
37
+ warnCount > 0 ? plural(warnCount, "warning") : "",
38
+ `${plural(passedCount, "check")} passed`,
39
+ ]
40
+ .filter(Boolean)
41
+ .join(" · ");
42
+ }
43
+
44
+ export function renderReport(report: DoctorReport, options: RenderOptions): string[] {
45
+ const mark = (severity: Severity) =>
46
+ options.color
47
+ ? `${MARK[severity].ansi}${MARK[severity].glyph}\x1b[0m`
48
+ : MARK[severity].glyph;
49
+ const of = (severity: Severity) =>
50
+ report.findings.filter((finding) => finding.severity === severity);
51
+ const fails = of("fail");
52
+ const warns = of("warn");
53
+ const optionals = of("optional");
54
+ const passes = of("ok");
55
+
56
+ const lines = [headline(report, passes.length)];
57
+ if (options.verbose) {
58
+ lines.push("", ...passes.map((finding) => ` ${mark("ok")} ${finding.title}`));
59
+ }
60
+ const problems = [...fails, ...warns];
61
+ if (problems.length > 0) lines.push("");
62
+ for (const problem of problems) {
63
+ lines.push(` ${mark(problem.severity)} ${problem.title}`);
64
+ if (problem.fix) lines.push(` → ${fixText(problem.fix)}`);
65
+ }
66
+ if (optionals.length > 0) {
67
+ lines.push("", "Optional — not set up:");
68
+ for (const item of optionals) {
69
+ const how = item.fix?.command ?? item.fix?.say;
70
+ const suffix = how ? `: ${how}` : "";
71
+ lines.push(` ${mark("optional")} ${item.title}${suffix}`);
72
+ }
73
+ }
74
+ if (problems.length > 0) {
75
+ lines.push("", summary(fails.length, warns.length, passes.length));
76
+ }
77
+ return lines;
78
+ }
79
+
80
+ export function doctorExitCode(findings: Finding[]): 0 | 1 {
81
+ return findings.some((finding) => finding.severity === "fail") ? 1 : 0;
82
+ }
@@ -0,0 +1,46 @@
1
+ import { getInstalledVersion } from "../../hooks/handlers/update-check";
2
+ import {
3
+ agentFindings,
4
+ type DoctorResult,
5
+ detectAgents,
6
+ installedAgents,
7
+ rosterFindings,
8
+ } from "./agents";
9
+ import { environmentFindings } from "./environment";
10
+ import type { Finding } from "./finding";
11
+ import { healthFindings } from "./health";
12
+ import { inferenceFindings } from "./inference";
13
+ import { doctorExitCode, renderReport } from "./render";
14
+ import { stateFindings } from "./state";
15
+
16
+ function doctorFindings(health: DoctorResult): Finding[] {
17
+ const agents = installedAgents(health);
18
+ return [
19
+ ...rosterFindings(agents),
20
+ ...environmentFindings(health.rtk),
21
+ ...stateFindings(),
22
+ ...agentFindings(agents),
23
+ ...inferenceFindings(agents),
24
+ ...healthFindings(),
25
+ ];
26
+ }
27
+
28
+ /** `pal cli doctor [--verbose] [--json]` — prints the report and returns the exit code. */
29
+ export function runDoctor(args: string[], health: DoctorResult = detectAgents()): number {
30
+ if (process.env.PAL_SKIP_DOCTOR === "1") return 0;
31
+ const report = {
32
+ version: getInstalledVersion(),
33
+ agents: installedAgents(health),
34
+ findings: doctorFindings(health),
35
+ };
36
+ if (args.includes("--json")) {
37
+ console.log(JSON.stringify(report, null, 2));
38
+ } else {
39
+ const lines = renderReport(report, {
40
+ verbose: args.includes("--verbose"),
41
+ color: process.stdout.isTTY === true,
42
+ });
43
+ console.log(lines.join("\n"));
44
+ }
45
+ return doctorExitCode(report.findings);
46
+ }