portable-agent-layer 0.2.1 → 0.3.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/README.md CHANGED
@@ -30,7 +30,10 @@ With PAL, you can:
30
30
 
31
31
  ### Prerequisites
32
32
 
33
+ > **Bun is required.** PAL is built on [Bun](https://bun.sh) and will not work with Node.js or other runtimes. Install it with `curl -fsSL https://bun.sh/install | bash`.
34
+
33
35
  - [Bun](https://bun.sh) >= 1.3.0
36
+ - At least one of: [Claude Code](https://claude.ai/code) or [opencode](https://opencode.ai)
34
37
 
35
38
  ### Package mode (recommended)
36
39
 
@@ -77,6 +80,7 @@ pal cli status # check your setup
77
80
  | `pal cli export` | Export user state (telos, memory) to a zip |
78
81
  | `pal cli import` | Import user state from a zip |
79
82
  | `pal cli status` | Show current PAL configuration |
83
+ | `pal cli doctor` | Check prerequisites and system health |
80
84
 
81
85
  ### Target flags
82
86
 
@@ -111,6 +115,27 @@ pal cli install # both (default)
111
115
 
112
116
  ---
113
117
 
118
+ ## Skills
119
+
120
+ PAL ships with built-in skills that extend your agent's capabilities:
121
+
122
+ | Skill | Description |
123
+ |-------|-------------|
124
+ | `analyze-pdf` | Download and analyze PDF files |
125
+ | `analyze-youtube` | Analyze YouTube videos using Gemini |
126
+ | `council` | Multi-perspective parallel debate on decisions |
127
+ | `create-skill` | Scaffold a new skill from a description |
128
+ | `extract-entities` | Extract people and companies from content |
129
+ | `extract-wisdom` | Extract structured insights from content |
130
+ | `first-principles` | Break down problems to fundamentals |
131
+ | `fyzz-chat-api` | Query Fyzz Chat conversations via API |
132
+ | `reflect` | Diagnose why a PAL behavior didn't trigger |
133
+ | `research` | Multi-agent parallel research |
134
+ | `review` | Security-focused code review |
135
+ | `summarize` | Structured summarization |
136
+
137
+ ---
138
+
114
139
  ## Core idea
115
140
 
116
141
  PAL stands for **Portable Agent Layer**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * export [path] [--dry-run] Export user state to zip
14
14
  * import [path] [--dry-run] Import user state from zip
15
15
  * status Show current PAL configuration
16
+ * doctor Check prerequisites and system health
16
17
  */
17
18
 
18
19
  import { spawnSync } from "node:child_process";
@@ -35,18 +36,53 @@ if (allArgs[0] === "cli") {
35
36
  await session(allArgs);
36
37
  }
37
38
 
38
- // ── Session: pal [claude-args] ──
39
+ // ── Session: pal [args] ──
39
40
 
40
- async function session(claudeArgs: string[]) {
41
- // Run claude with all args, inheriting stdio for interactive TTY
42
- const result = spawnSync("claude", claudeArgs, {
41
+ interface ToolCheck {
42
+ name: string;
43
+ available: boolean;
44
+ version?: string;
45
+ }
46
+
47
+ function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolCheck {
48
+ try {
49
+ const result = spawnSync(cmd, versionArgs, {
50
+ stdio: ["ignore", "pipe", "pipe"],
51
+ shell: true,
52
+ timeout: 5000,
53
+ });
54
+ if (result.status === 0) {
55
+ const version = (result.stdout?.toString() || "").trim().split("\n")[0];
56
+ return { name: cmd, available: true, version };
57
+ }
58
+ } catch {
59
+ // not found
60
+ }
61
+ return { name: cmd, available: false };
62
+ }
63
+
64
+ function detectAgent(): string | null {
65
+ if (checkTool("claude").available) return "claude";
66
+ if (checkTool("opencode").available) return "opencode";
67
+ return null;
68
+ }
69
+
70
+ async function session(sessionArgs: string[]) {
71
+ const agent = detectAgent();
72
+ if (!agent) {
73
+ log.error("No supported agent found. Install Claude Code or opencode.");
74
+ process.exit(1);
75
+ }
76
+
77
+ const result = spawnSync(agent, sessionArgs, {
43
78
  stdio: "inherit",
44
79
  shell: true,
45
80
  });
46
81
 
47
82
  const exitCode = result.status ?? 1;
48
83
 
49
- // Find the most recent transcript and extract session ID
84
+ // Session summary (Claude only)
85
+ if (agent !== "claude") process.exit(exitCode);
50
86
  try {
51
87
  const projectsDir = resolve(homedir(), ".claude", "projects");
52
88
  if (!existsSync(projectsDir)) process.exit(exitCode);
@@ -98,7 +134,7 @@ async function runCli(command: string | undefined, args: string[]) {
98
134
  break;
99
135
  case "install":
100
136
  banner();
101
- await install(parseTargets(args));
137
+ await install(resolveTargets(args));
102
138
  break;
103
139
  case "uninstall":
104
140
  await uninstall(args);
@@ -112,6 +148,9 @@ async function runCli(command: string | undefined, args: string[]) {
112
148
  case "status":
113
149
  await status();
114
150
  break;
151
+ case "doctor":
152
+ doctor();
153
+ break;
115
154
  case "--help":
116
155
  case "-h":
117
156
  case "help":
@@ -148,6 +187,7 @@ function showHelp() {
148
187
  pal cli export [path] [--dry-run] Export state to zip
149
188
  pal cli import [path] [--dry-run] Import state from zip
150
189
  pal cli status Show PAL configuration
190
+ pal cli doctor Check prerequisites and health
151
191
 
152
192
  Environment:
153
193
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
@@ -176,6 +216,103 @@ function parseTargets(args: string[]): {
176
216
  return { claude, opencode };
177
217
  }
178
218
 
219
+ /** Resolve targets against available agents. Errors if explicitly requested but missing. */
220
+ function resolveTargets(
221
+ args: string[],
222
+ health?: DoctorResult
223
+ ): { claude: boolean; opencode: boolean } {
224
+ const requested = parseTargets(args);
225
+ const h = health || doctor(true);
226
+ const explicit = args.some(
227
+ (a) => a === "--claude" || a === "--opencode" || a === "--all"
228
+ );
229
+
230
+ if (explicit) {
231
+ // User explicitly requested — error if not available
232
+ if (requested.claude && !h.claude.available) {
233
+ log.error("Claude Code is not installed. Run 'pal cli doctor' for details.");
234
+ process.exit(1);
235
+ }
236
+ if (requested.opencode && !h.opencode.available) {
237
+ log.error("opencode is not installed. Run 'pal cli doctor' for details.");
238
+ process.exit(1);
239
+ }
240
+ return requested;
241
+ }
242
+
243
+ // Default (no flags) — install for available agents only
244
+ const targets = {
245
+ claude: h.claude.available,
246
+ opencode: h.opencode.available,
247
+ };
248
+
249
+ if (!targets.claude) log.info("Skipping Claude Code (not installed)");
250
+ if (!targets.opencode) log.info("Skipping opencode (not installed)");
251
+
252
+ return targets;
253
+ }
254
+
255
+ // ── Doctor ──
256
+
257
+ interface DoctorResult {
258
+ bun: ToolCheck;
259
+ claude: ToolCheck;
260
+ opencode: ToolCheck;
261
+ hasAgent: boolean;
262
+ }
263
+
264
+ function doctor(silent = false): DoctorResult {
265
+ // Allow CI/tests to skip agent detection
266
+ if (process.env.PAL_SKIP_DOCTOR === "1") {
267
+ return {
268
+ bun: { name: "bun", available: true, version: Bun.version },
269
+ claude: { name: "claude", available: true },
270
+ opencode: { name: "opencode", available: true },
271
+ hasAgent: true,
272
+ };
273
+ }
274
+
275
+ const bun = { name: "bun", available: true, version: Bun.version };
276
+ const claude = checkTool("claude");
277
+ const opencode = checkTool("opencode");
278
+ const hasAgent = claude.available || opencode.available;
279
+
280
+ const home = palHome();
281
+ const isRepo = existsSync(resolve(palPkg(), ".palroot"));
282
+ const telosCount = (() => {
283
+ try {
284
+ return readdirSync(resolve(home, "telos")).filter((f) => f.endsWith(".md")).length;
285
+ } catch {
286
+ return 0;
287
+ }
288
+ })();
289
+
290
+ if (!silent) {
291
+ const ok = (msg: string) => log.info(` \u2713 ${msg}`);
292
+ const fail = (msg: string) => log.warn(` \u2717 ${msg}`);
293
+
294
+ console.log("");
295
+ log.info("Doctor");
296
+ ok(`Bun ${bun.version}`);
297
+ claude.available
298
+ ? ok(`Claude Code ${claude.version || ""}`.trim())
299
+ : fail("Claude Code — not found");
300
+ opencode.available
301
+ ? ok(`opencode ${opencode.version || ""}`.trim())
302
+ : fail("opencode — not found");
303
+ ok(`PAL home: ${home} (${isRepo ? "repo" : "package"} mode)`);
304
+ telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
305
+
306
+ if (!hasAgent) {
307
+ console.log("");
308
+ log.error("No supported agent found. Install Claude Code or opencode.");
309
+ }
310
+ console.log("");
311
+ }
312
+
313
+ return { bun, claude, opencode, hasAgent };
314
+ }
315
+
179
316
  // ── Commands ──
180
317
 
181
318
  async function init(args: string[]) {
@@ -184,6 +321,12 @@ async function init(args: string[]) {
184
321
 
185
322
  banner();
186
323
 
324
+ // Run doctor first — abort if no agents available
325
+ const health = doctor(false);
326
+ if (!health.hasAgent) {
327
+ process.exit(1);
328
+ }
329
+
187
330
  const home = palHome();
188
331
  const isRepo = existsSync(resolve(palPkg(), ".palroot"));
189
332
 
@@ -196,7 +339,9 @@ async function init(args: string[]) {
196
339
  scaffoldTelos();
197
340
  ensureSetupState();
198
341
 
199
- await install(parseTargets(args));
342
+ // Auto-detect available targets
343
+ const targets = resolveTargets(args, health);
344
+ await install(targets);
200
345
 
201
346
  console.log("");
202
347
  const state = ensureSetupState();
@@ -6,6 +6,7 @@
6
6
  * Transcript is read from the file at transcript_path, NOT from stdin.
7
7
  */
8
8
 
9
+ import { checkReadmeSync } from "./handlers/readme-sync";
9
10
  import { logError } from "./lib/log";
10
11
  import { readStdinJSON } from "./lib/stdin";
11
12
  import { runStopHandlers } from "./lib/stop";
@@ -17,6 +18,17 @@ interface StopHookInput {
17
18
  last_assistant_message?: string;
18
19
  }
19
20
 
21
+ // Check README sync before anything else — may block the session
22
+ try {
23
+ const decision = checkReadmeSync();
24
+ if (decision.decision === "block") {
25
+ console.log(JSON.stringify(decision));
26
+ process.exit(0);
27
+ }
28
+ } catch (err) {
29
+ logError("StopOrchestrator:readme-sync", err);
30
+ }
31
+
20
32
  const input = await readStdinJSON<StopHookInput>();
21
33
  if (!input?.transcript_path) {
22
34
  logError("StopOrchestrator", "No transcript_path in hook input");
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Stop handler: check if README.md is out of sync with code.
3
+ *
4
+ * Runs git diff to see if documentable files changed in this session.
5
+ * If they did and README is stale, returns a block decision.
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+ import { logDebug } from "../lib/log";
10
+ import { palPkg } from "../lib/paths";
11
+ import { validateReadmeSync, WATCHED_PATHS } from "../lib/readme-sync";
12
+
13
+ /** Check if any watched files have uncommitted changes. */
14
+ function hasDocumentableChanges(): boolean {
15
+ try {
16
+ const diff = execSync("git diff --name-only HEAD", {
17
+ cwd: palPkg(),
18
+ encoding: "utf-8",
19
+ }).trim();
20
+
21
+ const staged = execSync("git diff --name-only --cached", {
22
+ cwd: palPkg(),
23
+ encoding: "utf-8",
24
+ }).trim();
25
+
26
+ const changed = `${diff}\n${staged}`.split("\n").filter((f) => f.length > 0);
27
+
28
+ return changed.some((file) =>
29
+ WATCHED_PATHS.some((watched) => file === watched || file.startsWith(`${watched}/`))
30
+ );
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export interface ReadmeSyncDecision {
37
+ decision?: "block";
38
+ reason?: string;
39
+ }
40
+
41
+ /** Returns a block decision if README is stale, or empty object to allow stop. */
42
+ export function checkReadmeSync(): ReadmeSyncDecision {
43
+ if (!hasDocumentableChanges()) {
44
+ logDebug("readme-sync", "No documentable changes detected");
45
+ return {};
46
+ }
47
+
48
+ logDebug("readme-sync", "Documentable files changed — validating README");
49
+ const result = validateReadmeSync();
50
+
51
+ if (!result.ok) {
52
+ logDebug("readme-sync", `README out of sync: ${result.issues.join("; ")}`);
53
+ return {
54
+ decision: "block",
55
+ reason: `README.md is out of date. Please update it before finishing:\n${result.issues.map((i) => `- ${i}`).join("\n")}`,
56
+ };
57
+ }
58
+
59
+ logDebug("readme-sync", "README is in sync");
60
+ return {};
61
+ }
@@ -18,7 +18,7 @@ import {
18
18
  } from "node:fs";
19
19
  import { dirname, relative, resolve } from "node:path";
20
20
  import { loadTelos } from "./context";
21
- import { assets, palHome, paths, platform } from "./paths";
21
+ import { assets, ensureDir, palHome, paths, platform } from "./paths";
22
22
  import { buildSetupPrompt, readSetupState } from "./setup";
23
23
 
24
24
  const TEMPLATE_PATH = assets.agentsMdTemplate();
@@ -116,6 +116,7 @@ export function regenerateIfNeeded(): boolean {
116
116
  const { outputPath } = getOutputPaths();
117
117
  ensureSymlink();
118
118
  if (!needsRebuild()) return false;
119
+ ensureDir(dirname(outputPath));
119
120
  writeFileSync(outputPath, buildClaudeMd(), "utf-8");
120
121
  return true;
121
122
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * README sync validation — ensures README.md reflects current code surfaces.
3
+ *
4
+ * Checks CLI commands, environment variables, and skills against README content.
5
+ * Used by tests (CI/pre-commit) and the Stop hook (blocks session if stale).
6
+ */
7
+
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { palPkg } from "./paths";
11
+
12
+ export interface SyncResult {
13
+ ok: boolean;
14
+ issues: string[];
15
+ }
16
+
17
+ /** Files that, when changed, should trigger a README check. */
18
+ export const WATCHED_PATHS = [
19
+ "src/cli/index.ts",
20
+ "src/hooks/lib/paths.ts",
21
+ "src/hooks/lib/inference.ts",
22
+ "src/tools/youtube-analyze.ts",
23
+ "assets/skills",
24
+ "assets/agents",
25
+ ];
26
+
27
+ /** Extract CLI command names from the switch statement in index.ts */
28
+ function extractCliCommands(): string[] {
29
+ const pkg = palPkg();
30
+ const cliPath = resolve(pkg, "src", "cli", "index.ts");
31
+ if (!existsSync(cliPath)) return [];
32
+
33
+ const content = readFileSync(cliPath, "utf-8");
34
+ const matches = content.matchAll(/case\s+"([^"]+)":/g);
35
+ const commands: string[] = [];
36
+
37
+ for (const match of matches) {
38
+ const cmd = match[1];
39
+ // Skip help aliases and internal routing
40
+ if (["--help", "-h", "help", "cli"].includes(cmd)) continue;
41
+ commands.push(cmd);
42
+ }
43
+
44
+ return [...new Set(commands)];
45
+ }
46
+
47
+ /** Extract PAL_* env var names from paths.ts + API keys from source */
48
+ function extractEnvVars(): string[] {
49
+ const pkg = palPkg();
50
+ const vars: Set<string> = new Set();
51
+
52
+ // PAL_* from paths.ts
53
+ const pathsFile = resolve(pkg, "src", "hooks", "lib", "paths.ts");
54
+ if (existsSync(pathsFile)) {
55
+ const content = readFileSync(pathsFile, "utf-8");
56
+ for (const match of content.matchAll(/process\.env\.(PAL_\w+)/g)) {
57
+ vars.add(match[1]);
58
+ }
59
+ }
60
+
61
+ // ANTHROPIC_API_KEY from inference.ts
62
+ const inferenceFile = resolve(pkg, "src", "hooks", "lib", "inference.ts");
63
+ if (existsSync(inferenceFile)) {
64
+ const content = readFileSync(inferenceFile, "utf-8");
65
+ if (content.includes("ANTHROPIC_API_KEY")) {
66
+ vars.add("ANTHROPIC_API_KEY");
67
+ }
68
+ }
69
+
70
+ // GEMINI_API_KEY from youtube-analyze.ts
71
+ const youtubeFile = resolve(pkg, "src", "tools", "youtube-analyze.ts");
72
+ if (existsSync(youtubeFile)) {
73
+ const content = readFileSync(youtubeFile, "utf-8");
74
+ if (content.includes("GEMINI_API_KEY")) {
75
+ vars.add("GEMINI_API_KEY");
76
+ }
77
+ }
78
+
79
+ return [...vars];
80
+ }
81
+
82
+ /** Extract skill names from assets/skills/ */
83
+ function extractSkillNames(): string[] {
84
+ const pkg = palPkg();
85
+ const skillsDir = resolve(pkg, "assets", "skills");
86
+ if (!existsSync(skillsDir)) return [];
87
+
88
+ return readdirSync(skillsDir)
89
+ .filter((f) => f.endsWith(".md"))
90
+ .map((f) => f.replace(/\.md$/, ""));
91
+ }
92
+
93
+ /** Validate that README.md documents all code surfaces. */
94
+ export function validateReadmeSync(): SyncResult {
95
+ const pkg = palPkg();
96
+ const readmePath = resolve(pkg, "README.md");
97
+
98
+ if (!existsSync(readmePath)) {
99
+ return { ok: false, issues: ["README.md not found"] };
100
+ }
101
+
102
+ const readme = readFileSync(readmePath, "utf-8");
103
+ const issues: string[] = [];
104
+
105
+ // Check CLI commands
106
+ for (const cmd of extractCliCommands()) {
107
+ if (!readme.includes(`pal cli ${cmd}`)) {
108
+ issues.push(`CLI command "${cmd}" exists in code but not documented in README`);
109
+ }
110
+ }
111
+
112
+ // Check environment variables
113
+ for (const envVar of extractEnvVars()) {
114
+ if (!readme.includes(envVar)) {
115
+ issues.push(
116
+ `Environment variable "${envVar}" used in code but not documented in README`
117
+ );
118
+ }
119
+ }
120
+
121
+ // Check skills — just verify the count is mentioned or each name appears
122
+ const skills = extractSkillNames();
123
+ const undocumentedSkills = skills.filter((name) => !readme.includes(name));
124
+ if (undocumentedSkills.length > 0) {
125
+ issues.push(`Skills not documented in README: ${undocumentedSkills.join(", ")}`);
126
+ }
127
+
128
+ return { ok: issues.length === 0, issues };
129
+ }
@@ -48,6 +48,7 @@ export function scaffoldTelos(): void {
48
48
  const templatesDir = assets.telosTemplates();
49
49
  const telosDir = resolve(palHome(), "telos");
50
50
  if (!existsSync(templatesDir)) return;
51
+ mkdirSync(telosDir, { recursive: true });
51
52
 
52
53
  for (const file of readdirSync(templatesDir).filter((f) => f.endsWith(".md"))) {
53
54
  const src = resolve(templatesDir, file);