portable-agent-layer 0.85.10 → 0.85.12
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/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/doctor/agents.ts +336 -0
- package/src/cli/doctor/environment.ts +129 -0
- package/src/cli/doctor/finding.ts +35 -0
- package/src/cli/doctor/health.ts +54 -0
- package/src/cli/doctor/inference.ts +91 -0
- package/src/cli/doctor/os-release.ts +30 -0
- package/src/cli/doctor/render.ts +82 -0
- package/src/cli/doctor/run.ts +46 -0
- package/src/cli/doctor/state.ts +259 -0
- package/src/cli/doctor/version-control.ts +108 -0
- package/src/cli/index.ts +10 -624
- package/src/hooks/lib/log.ts +24 -11
- package/src/hooks/lib/projects.ts +0 -16
- package/src/cli/doctor-tools.ts +0 -133
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ pal cli status # check your setup
|
|
|
81
81
|
| `pal cli export` | Export user state (telos, memory) to a zip |
|
|
82
82
|
| `pal cli import` | Import user state from a zip |
|
|
83
83
|
| `pal cli status` | Show current PAL configuration |
|
|
84
|
-
| `pal cli doctor` |
|
|
84
|
+
| `pal cli doctor` | Find what is wrong and the command that fixes each problem (`--verbose` lists every check, `--json` for agents); exits 1 on any failure |
|
|
85
85
|
| `pal cli migrate` | Run pending data migrations (non-destructive) |
|
|
86
86
|
| `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
|
|
87
87
|
| `pal cli usage` | Summarize token usage and estimated cost |
|
package/package.json
CHANGED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { palPkg, platform } from "../../hooks/lib/paths";
|
|
5
|
+
import { NO_SESSION_AGENT_MESSAGE } from "../session-agent";
|
|
6
|
+
import { type Finding, failing, optional, passed, warning } from "./finding";
|
|
7
|
+
|
|
8
|
+
export type AgentName = "claude" | "opencode" | "cursor" | "copilot" | "codex";
|
|
9
|
+
|
|
10
|
+
const AGENT_NAMES: AgentName[] = ["claude", "codex", "copilot", "cursor", "opencode"];
|
|
11
|
+
|
|
12
|
+
export interface ToolCheck {
|
|
13
|
+
name: string;
|
|
14
|
+
available: boolean;
|
|
15
|
+
version?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DoctorResult {
|
|
19
|
+
bun: ToolCheck;
|
|
20
|
+
claude: ToolCheck;
|
|
21
|
+
opencode: ToolCheck;
|
|
22
|
+
cursor: ToolCheck;
|
|
23
|
+
copilot: ToolCheck;
|
|
24
|
+
codex: ToolCheck;
|
|
25
|
+
rtk: ToolCheck;
|
|
26
|
+
hasAgent: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolCheck {
|
|
30
|
+
try {
|
|
31
|
+
const result = spawnSync(cmd, versionArgs, {
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
33
|
+
shell: true,
|
|
34
|
+
timeout: 5000,
|
|
35
|
+
});
|
|
36
|
+
if (result.status === 0) {
|
|
37
|
+
const version = (result.stdout?.toString() || "").trim().split("\n")[0];
|
|
38
|
+
return { name: cmd, available: true, version };
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// not found
|
|
42
|
+
}
|
|
43
|
+
return { name: cmd, available: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Copilot ships as a VS Code extension as well as a CLI, and the extension puts
|
|
48
|
+
* no `copilot` binary on PATH — but both read hooks, skills and agents out of
|
|
49
|
+
* ~/.copilot. Fall back to that directory so PAL's copilot checks still run.
|
|
50
|
+
*/
|
|
51
|
+
function checkCopilot(): ToolCheck {
|
|
52
|
+
const cli = checkTool("copilot", ["version"]);
|
|
53
|
+
if (cli.available) return cli;
|
|
54
|
+
if (existsSync(platform.copilotDir())) {
|
|
55
|
+
return { name: "copilot", available: true, version: "~/.copilot (no CLI on PATH)" };
|
|
56
|
+
}
|
|
57
|
+
return cli;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The Cursor CLI installs `cursor-agent` (and `agent`) and no `cursor` command;
|
|
62
|
+
* `cursor` is the editor's shell command. Both read ~/.cursor, so either counts.
|
|
63
|
+
*/
|
|
64
|
+
function checkCursor(): ToolCheck {
|
|
65
|
+
const cli = checkTool("cursor-agent");
|
|
66
|
+
if (cli.available) return cli;
|
|
67
|
+
return checkTool("cursor");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function detectAgents(): DoctorResult {
|
|
71
|
+
if (process.env.PAL_SKIP_DOCTOR === "1") {
|
|
72
|
+
return {
|
|
73
|
+
bun: { name: "bun", available: true, version: Bun.version },
|
|
74
|
+
claude: { name: "claude", available: true },
|
|
75
|
+
opencode: { name: "opencode", available: true },
|
|
76
|
+
cursor: { name: "cursor", available: true },
|
|
77
|
+
copilot: { name: "copilot", available: true },
|
|
78
|
+
codex: { name: "codex", available: true },
|
|
79
|
+
rtk: { name: "rtk", available: true },
|
|
80
|
+
hasAgent: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const claude = checkTool("claude");
|
|
84
|
+
const opencode = checkTool("opencode");
|
|
85
|
+
const cursor = checkCursor();
|
|
86
|
+
const copilot = checkCopilot();
|
|
87
|
+
const codex = checkTool("codex");
|
|
88
|
+
return {
|
|
89
|
+
bun: { name: "bun", available: true, version: Bun.version },
|
|
90
|
+
claude,
|
|
91
|
+
opencode,
|
|
92
|
+
cursor,
|
|
93
|
+
copilot,
|
|
94
|
+
codex,
|
|
95
|
+
rtk: checkTool("rtk"),
|
|
96
|
+
hasAgent: [claude, opencode, cursor, copilot, codex].some((tool) => tool.available),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function installedAgents(result: DoctorResult): AgentName[] {
|
|
101
|
+
return AGENT_NAMES.filter((name) => result[name].available);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface AgentLayout {
|
|
105
|
+
label: string;
|
|
106
|
+
skillsDir: () => string;
|
|
107
|
+
hookFile?: () => string;
|
|
108
|
+
instructions?: { file: () => string; name: string };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const LAYOUT: Record<AgentName, AgentLayout> = {
|
|
112
|
+
claude: {
|
|
113
|
+
label: "Claude Code",
|
|
114
|
+
skillsDir: () => resolve(platform.claudeDir(), "skills"),
|
|
115
|
+
hookFile: () => resolve(platform.claudeDir(), "settings.json"),
|
|
116
|
+
instructions: {
|
|
117
|
+
file: () => resolve(platform.claudeDir(), "CLAUDE.md"),
|
|
118
|
+
name: "CLAUDE.md",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
codex: {
|
|
122
|
+
label: "Codex",
|
|
123
|
+
skillsDir: () => resolve(platform.codexDir(), "skills"),
|
|
124
|
+
hookFile: () => resolve(platform.codexDir(), "hooks.json"),
|
|
125
|
+
},
|
|
126
|
+
copilot: {
|
|
127
|
+
label: "Copilot",
|
|
128
|
+
skillsDir: () => resolve(platform.copilotDir(), "skills"),
|
|
129
|
+
hookFile: () => resolve(platform.copilotDir(), "hooks", "pal-hooks.json"),
|
|
130
|
+
},
|
|
131
|
+
cursor: {
|
|
132
|
+
label: "Cursor",
|
|
133
|
+
skillsDir: () => resolve(platform.cursorDir(), "skills"),
|
|
134
|
+
hookFile: () => resolve(platform.cursorDir(), "hooks.json"),
|
|
135
|
+
},
|
|
136
|
+
opencode: {
|
|
137
|
+
label: "opencode",
|
|
138
|
+
skillsDir: () => resolve(platform.agentsDir(), "skills"),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const reinstall = (agent: AgentName) => ({
|
|
143
|
+
say: `Reinstall PAL for ${LAYOUT[agent].label}`,
|
|
144
|
+
command: `pal cli install --${agent}`,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** Hook-config keys that carry a shell command: cross-platform and per-shell variants. */
|
|
148
|
+
function isHookCommandField(key: string): boolean {
|
|
149
|
+
return key === "command" || key === "bash" || key === "powershell";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extractAllHookCommands(obj: unknown, out: string[] = []): string[] {
|
|
153
|
+
if (Array.isArray(obj)) {
|
|
154
|
+
for (const item of obj) extractAllHookCommands(item, out);
|
|
155
|
+
} else if (obj && typeof obj === "object") {
|
|
156
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
157
|
+
if (isHookCommandField(k) && typeof v === "string") {
|
|
158
|
+
out.push(v);
|
|
159
|
+
} else {
|
|
160
|
+
extractAllHookCommands(v, out);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* True when a hook command names its agent, by env prefix or by argv flag.
|
|
169
|
+
*
|
|
170
|
+
* An env prefix only parses in one shell family, so hook configs whose host
|
|
171
|
+
* shell is unknown declare the agent with a shell-agnostic `--agent=` flag.
|
|
172
|
+
*/
|
|
173
|
+
function declaresAgent(cmd: string, agentName: string): boolean {
|
|
174
|
+
return (
|
|
175
|
+
cmd.startsWith(`PAL_AGENT=${agentName} `) ||
|
|
176
|
+
cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `) ||
|
|
177
|
+
cmd.includes(`--agent=${agentName}`)
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const SCRIPT =
|
|
182
|
+
/"([^"]+\.(?:ts|js|mjs|sh|ps1))"|'([^']+\.(?:ts|js|mjs|sh|ps1))'|(\S+\.(?:ts|js|mjs|sh|ps1))(?=\s|$)/g;
|
|
183
|
+
|
|
184
|
+
function scriptPaths(cmd: string): string[] {
|
|
185
|
+
return Array.from(cmd.matchAll(SCRIPT), (m) => m[1] ?? m[2] ?? m[3] ?? "").filter(
|
|
186
|
+
isAbsolute
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isPalHook(cmd: string, agent: AgentName): boolean {
|
|
191
|
+
return (
|
|
192
|
+
declaresAgent(cmd, agent) ||
|
|
193
|
+
scriptPaths(cmd).some((path) => /[\\/]src[\\/]hooks[\\/][^\\/]+$/.test(path))
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readHookCommands(file: string): string[] | "missing" | "unreadable" {
|
|
198
|
+
if (!existsSync(file)) return "missing";
|
|
199
|
+
try {
|
|
200
|
+
const data = JSON.parse(readFileSync(file, "utf-8"));
|
|
201
|
+
return extractAllHookCommands(data.hooks ?? data);
|
|
202
|
+
} catch {
|
|
203
|
+
return "unreadable";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function hookFindings(agent: AgentName, file: string): Finding[] {
|
|
208
|
+
const label = LAYOUT[agent].label;
|
|
209
|
+
const commands = readHookCommands(file);
|
|
210
|
+
if (commands === "unreadable")
|
|
211
|
+
return [
|
|
212
|
+
failing(
|
|
213
|
+
`${agent}.hooks.unreadable`,
|
|
214
|
+
`${label} hook config is not valid JSON (${file})`,
|
|
215
|
+
{
|
|
216
|
+
say: `Fix the JSON in ${file}, then reinstall`,
|
|
217
|
+
command: `pal cli install --${agent}`,
|
|
218
|
+
}
|
|
219
|
+
),
|
|
220
|
+
];
|
|
221
|
+
const pal = commands === "missing" ? [] : commands.filter((c) => isPalHook(c, agent));
|
|
222
|
+
if (!pal.some((c) => c.includes("LoadContext")))
|
|
223
|
+
return [
|
|
224
|
+
failing(
|
|
225
|
+
`${agent}.hooks.missing`,
|
|
226
|
+
`${label} hooks are not registered`,
|
|
227
|
+
reinstall(agent)
|
|
228
|
+
),
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const findings = [passed(`${agent}.hooks`, `${label}: ${pal.length} hooks registered`)];
|
|
232
|
+
const undeclared = pal.filter((c) => !declaresAgent(c, agent));
|
|
233
|
+
if (undeclared.length > 0)
|
|
234
|
+
findings.push(
|
|
235
|
+
failing(
|
|
236
|
+
`${agent}.hooks.undeclared`,
|
|
237
|
+
`${label}: ${undeclared.length} of ${pal.length} hooks do not name ${agent} as their agent`,
|
|
238
|
+
reinstall(agent)
|
|
239
|
+
)
|
|
240
|
+
);
|
|
241
|
+
const broken = pal.filter((c) => scriptPaths(c).some((path) => !existsSync(path)));
|
|
242
|
+
if (broken.length > 0)
|
|
243
|
+
findings.push(
|
|
244
|
+
failing(
|
|
245
|
+
`${agent}.hooks.scripts`,
|
|
246
|
+
`${label}: ${broken.length} of ${pal.length} hooks run a script that no longer exists (${scriptPaths(broken[0]).find((p) => !existsSync(p))})`,
|
|
247
|
+
reinstall(agent)
|
|
248
|
+
)
|
|
249
|
+
);
|
|
250
|
+
return findings;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function opencodePluginFindings(): Finding[] {
|
|
254
|
+
const installed = resolve(platform.opencodeDir(), "plugins", "pal-plugin.ts");
|
|
255
|
+
const source = resolve(palPkg(), "src", "targets", "opencode", "plugin.ts");
|
|
256
|
+
if (!existsSync(installed))
|
|
257
|
+
return [
|
|
258
|
+
failing(
|
|
259
|
+
"opencode.hooks.missing",
|
|
260
|
+
"opencode plugin is not installed",
|
|
261
|
+
reinstall("opencode")
|
|
262
|
+
),
|
|
263
|
+
];
|
|
264
|
+
if (existsSync(source) && statSync(installed).mtimeMs < statSync(source).mtimeMs)
|
|
265
|
+
return [
|
|
266
|
+
warning(
|
|
267
|
+
"opencode.plugin.stale",
|
|
268
|
+
"opencode plugin is older than PAL's — recent fixes have not reached opencode",
|
|
269
|
+
reinstall("opencode")
|
|
270
|
+
),
|
|
271
|
+
];
|
|
272
|
+
return [passed("opencode.hooks", "opencode plugin installed and current")];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function countSkills(dir: string): number {
|
|
276
|
+
if (!existsSync(dir)) return 0;
|
|
277
|
+
return readdirSync(dir).filter((f) => existsSync(resolve(dir, f, "SKILL.md"))).length;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function oneAgentFindings(agent: AgentName): Finding[] {
|
|
281
|
+
const layout = LAYOUT[agent];
|
|
282
|
+
const skills = countSkills(layout.skillsDir());
|
|
283
|
+
const findings = [
|
|
284
|
+
skills > 0
|
|
285
|
+
? passed(`${agent}.skills`, `${layout.label}: ${skills} skills`)
|
|
286
|
+
: warning(`${agent}.skills`, `${layout.label} has no PAL skills`, reinstall(agent)),
|
|
287
|
+
];
|
|
288
|
+
findings.push(
|
|
289
|
+
...(layout.hookFile
|
|
290
|
+
? hookFindings(agent, layout.hookFile())
|
|
291
|
+
: opencodePluginFindings())
|
|
292
|
+
);
|
|
293
|
+
if (layout.instructions) {
|
|
294
|
+
const { file, name } = layout.instructions;
|
|
295
|
+
findings.push(
|
|
296
|
+
existsSync(file())
|
|
297
|
+
? passed(`${agent}.instructions`, `${name} present`)
|
|
298
|
+
: failing(`${agent}.instructions`, `${name} is missing`, reinstall(agent))
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return findings;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function agentFindings(agents: AgentName[]): Finding[] {
|
|
305
|
+
if (agents.length === 0) return [];
|
|
306
|
+
const agentsMd = resolve(platform.opencodeDir(), "AGENTS.md");
|
|
307
|
+
return [
|
|
308
|
+
existsSync(agentsMd)
|
|
309
|
+
? passed("agents-md", "AGENTS.md present")
|
|
310
|
+
: failing("agents-md", "AGENTS.md is missing", {
|
|
311
|
+
say: "Reinstall PAL",
|
|
312
|
+
command: "pal cli install",
|
|
313
|
+
}),
|
|
314
|
+
...agents.flatMap(oneAgentFindings),
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function rosterFindings(agents: AgentName[]): Finding[] {
|
|
319
|
+
if (agents.length === 0)
|
|
320
|
+
return [
|
|
321
|
+
failing("agents.none", "No supported agent is installed", {
|
|
322
|
+
say: NO_SESSION_AGENT_MESSAGE,
|
|
323
|
+
}),
|
|
324
|
+
];
|
|
325
|
+
const others = AGENT_NAMES.filter((name) => !agents.includes(name));
|
|
326
|
+
if (others.length === 0) return [];
|
|
327
|
+
return [
|
|
328
|
+
optional(
|
|
329
|
+
"agents.others",
|
|
330
|
+
`Other agents PAL supports — ${others.map((name) => LAYOUT[name].label).join(", ")}`,
|
|
331
|
+
{
|
|
332
|
+
say: `install it, then run pal cli install --${others.length === 1 ? others[0] : "<agent>"}`,
|
|
333
|
+
}
|
|
334
|
+
),
|
|
335
|
+
];
|
|
336
|
+
}
|
|
@@ -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
|
+
}
|