portable-agent-layer 0.1.0 → 0.2.1

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
@@ -26,6 +26,91 @@ With PAL, you can:
26
26
 
27
27
  ---
28
28
 
29
+ ## Install
30
+
31
+ ### Prerequisites
32
+
33
+ - [Bun](https://bun.sh) >= 1.3.0
34
+
35
+ ### Package mode (recommended)
36
+
37
+ ```bash
38
+ bun add -g portable-agent-layer
39
+ pal cli init
40
+ ```
41
+
42
+ ### Repo mode (for development / contributors)
43
+
44
+ ```bash
45
+ git clone https://github.com/kovrichard/portable-agent-layer.git
46
+ cd portable-agent-layer
47
+ bun install
48
+ bun run install:all
49
+ ```
50
+
51
+ In repo mode, add an alias to your shell profile:
52
+
53
+ ```bash
54
+ alias pal="bun run ~/path/to/portable-agent-layer/src/cli/index.ts"
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Quick start
60
+
61
+ ```bash
62
+ pal cli init # scaffold home, install hooks for all targets
63
+ pal # start a Claude session (with session summary on exit)
64
+ pal cli status # check your setup
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Commands
70
+
71
+ | Command | Description |
72
+ |---------|-------------|
73
+ | `pal` | Start a Claude session with session summary on exit |
74
+ | `pal cli init` | Scaffold PAL home directory and install hooks |
75
+ | `pal cli install` | Register hooks/skills for targets |
76
+ | `pal cli uninstall` | Remove hooks/skills for targets |
77
+ | `pal cli export` | Export user state (telos, memory) to a zip |
78
+ | `pal cli import` | Import user state from a zip |
79
+ | `pal cli status` | Show current PAL configuration |
80
+
81
+ ### Target flags
82
+
83
+ `init`, `install`, and `uninstall` accept target flags:
84
+
85
+ ```bash
86
+ pal cli install --claude # Claude Code only
87
+ pal cli install --opencode # opencode only
88
+ pal cli install # both (default)
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Environment variables
94
+
95
+ ### Required
96
+
97
+ | Variable | Description |
98
+ |----------|-------------|
99
+ | `ANTHROPIC_API_KEY` | Required for PAL's hook inference (sentiment analysis, session naming). Uses Haiku for low-cost background calls. |
100
+
101
+ ### Optional
102
+
103
+ | Variable | Description |
104
+ |----------|-------------|
105
+ | `GEMINI_API_KEY` | For YouTube video analysis skill |
106
+ | `PAL_HOME` | Override user state directory (default: `~/.pal` or repo root) |
107
+ | `PAL_PKG` | Override package root |
108
+ | `PAL_CLAUDE_DIR` | Override Claude config dir (default: `~/.claude`) |
109
+ | `PAL_OPENCODE_DIR` | Override opencode config dir (default: `~/.config/opencode`) |
110
+ | `PAL_AGENTS_DIR` | Override agents dir (default: `~/.agents`) |
111
+
112
+ ---
113
+
29
114
  ## Core idea
30
115
 
31
116
  PAL stands for **Portable Agent Layer**.
@@ -78,3 +163,9 @@ PAL is for people who want:
78
163
  - to move between machines without rebuilding everything
79
164
  - a durable way to store and reuse context
80
165
  - an open foundation for portable agent workflows
166
+
167
+ ---
168
+
169
+ ## License
170
+
171
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@
9
9
  "files": [
10
10
  "src/",
11
11
  "assets/",
12
- "bin/",
13
12
  "README.md",
14
13
  "LICENSE"
15
14
  ],
package/src/cli/index.ts CHANGED
@@ -2,23 +2,129 @@
2
2
  /**
3
3
  * PAL CLI — Portable Agent Layer
4
4
  *
5
- * Usage: pal <command> [options]
5
+ * Usage:
6
+ * pal [claude-args...] Start a Claude session with session summary on exit
7
+ * pal cli <command> [options] Admin commands
6
8
  *
7
- * Commands:
8
- * init Scaffold PAL home, install hooks for all targets
9
- * install Register hooks/skills for targets
10
- * uninstall Remove hooks/skills for targets
11
- * export Export user state (telos, memory) to a zip
12
- * import Import user state from a zip
13
- * status Show current PAL configuration
9
+ * Admin commands (pal cli ...):
10
+ * init Scaffold PAL home, install hooks for all targets
11
+ * install [--claude] [--opencode] Register hooks/skills for targets
12
+ * uninstall [--claude] [--opencode] Remove hooks/skills for targets
13
+ * export [path] [--dry-run] Export user state to zip
14
+ * import [path] [--dry-run] Import user state from zip
15
+ * status Show current PAL configuration
14
16
  */
15
17
 
16
- import { existsSync, mkdirSync } from "node:fs";
18
+ import { spawnSync } from "node:child_process";
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
20
+ import { homedir } from "node:os";
17
21
  import { resolve } from "node:path";
18
22
  import { palHome, palPkg, platform } from "../hooks/lib/paths";
19
23
  import { log } from "../targets/lib";
20
24
 
21
- const [command, ...args] = process.argv.slice(2);
25
+ const allArgs = process.argv.slice(2);
26
+
27
+ // ── Route: pal cli <command> or pal [claude-args] ──
28
+
29
+ if (allArgs[0] === "cli") {
30
+ const [, command, ...args] = allArgs;
31
+ await runCli(command, args);
32
+ } else if (allArgs[0] === "--help" || allArgs[0] === "-h" || allArgs[0] === "help") {
33
+ showHelp();
34
+ } else {
35
+ await session(allArgs);
36
+ }
37
+
38
+ // ── Session: pal [claude-args] ──
39
+
40
+ async function session(claudeArgs: string[]) {
41
+ // Run claude with all args, inheriting stdio for interactive TTY
42
+ const result = spawnSync("claude", claudeArgs, {
43
+ stdio: "inherit",
44
+ shell: true,
45
+ });
46
+
47
+ const exitCode = result.status ?? 1;
48
+
49
+ // Find the most recent transcript and extract session ID
50
+ try {
51
+ const projectsDir = resolve(homedir(), ".claude", "projects");
52
+ if (!existsSync(projectsDir)) process.exit(exitCode);
53
+
54
+ // Find most recently modified .jsonl file
55
+ let latestFile = "";
56
+ let latestMtime = 0;
57
+
58
+ for (const project of readdirSync(projectsDir, { withFileTypes: true })) {
59
+ if (!project.isDirectory()) continue;
60
+ const dir = resolve(projectsDir, project.name);
61
+ for (const file of readdirSync(dir)) {
62
+ if (!file.endsWith(".jsonl")) continue;
63
+ const filepath = resolve(dir, file);
64
+ const { mtimeMs } = statSync(filepath);
65
+ if (mtimeMs > latestMtime) {
66
+ latestMtime = mtimeMs;
67
+ latestFile = filepath;
68
+ }
69
+ }
70
+ }
71
+
72
+ if (latestFile) {
73
+ const content = readFileSync(latestFile, "utf-8").trim();
74
+ const lastLine = content.split("\n").pop();
75
+ if (lastLine) {
76
+ const sessionId = JSON.parse(lastLine).sessionId;
77
+ if (sessionId) {
78
+ const summaryScript = resolve(palPkg(), "src", "tools", "session-summary.ts");
79
+ spawnSync("bun", ["run", summaryScript, "--", "--session", sessionId], {
80
+ stdio: "inherit",
81
+ });
82
+ }
83
+ }
84
+ }
85
+ } catch {
86
+ // Silently ignore summary errors
87
+ }
88
+
89
+ process.exit(exitCode);
90
+ }
91
+
92
+ // ── CLI dispatcher ──
93
+
94
+ async function runCli(command: string | undefined, args: string[]) {
95
+ switch (command) {
96
+ case "init":
97
+ await init(args);
98
+ break;
99
+ case "install":
100
+ banner();
101
+ await install(parseTargets(args));
102
+ break;
103
+ case "uninstall":
104
+ await uninstall(args);
105
+ break;
106
+ case "export":
107
+ await exportState(args);
108
+ break;
109
+ case "import":
110
+ await importState(args);
111
+ break;
112
+ case "status":
113
+ await status();
114
+ break;
115
+ case "--help":
116
+ case "-h":
117
+ case "help":
118
+ showHelp();
119
+ break;
120
+ default:
121
+ if (command) log.error(`Unknown command: ${command}`);
122
+ showHelp();
123
+ process.exit(command ? 1 : 0);
124
+ }
125
+ }
126
+
127
+ // ── Helpers ──
22
128
 
23
129
  function banner() {
24
130
  console.log("");
@@ -31,15 +137,18 @@ function banner() {
31
137
 
32
138
  function showHelp() {
33
139
  console.log(`
34
- Usage: pal <command> [options]
35
-
36
- Commands:
37
- init [--claude] [--opencode] [--all] Scaffold and install (default: all)
38
- install [--claude] [--opencode] [--all] Register hooks for targets
39
- uninstall [--claude] [--opencode] [--all] Remove hooks for targets
40
- export [path] [--dry-run] Export state to zip
41
- import [path] [--dry-run] Import state from zip
42
- status Show PAL configuration
140
+ Usage:
141
+ pal [claude-args...] Start a Claude session
142
+ pal cli <command> [options] Admin commands
143
+
144
+ Admin commands:
145
+ pal cli init [--claude] [--opencode] Scaffold and install (default: all)
146
+ pal cli install [--claude] [--opencode] Register hooks for targets
147
+ pal cli uninstall [--claude] [--opencode] Remove hooks for targets
148
+ pal cli export [path] [--dry-run] Export state to zip
149
+ pal cli import [path] [--dry-run] Import state from zip
150
+ pal cli status Show PAL configuration
151
+
43
152
  Environment:
44
153
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
45
154
  PAL_PKG Override package root
@@ -53,8 +162,6 @@ function parseTargets(args: string[]): {
53
162
  claude: boolean;
54
163
  opencode: boolean;
55
164
  } {
56
- if (args.length === 0) return { claude: true, opencode: true };
57
-
58
165
  let claude = false;
59
166
  let opencode = false;
60
167
  for (const arg of args) {
@@ -65,14 +172,13 @@ function parseTargets(args: string[]): {
65
172
  opencode = true;
66
173
  }
67
174
  }
68
- // If no target flags, default to all
69
175
  if (!claude && !opencode) return { claude: true, opencode: true };
70
176
  return { claude, opencode };
71
177
  }
72
178
 
73
179
  // ── Commands ──
74
180
 
75
- async function init() {
181
+ async function init(args: string[]) {
76
182
  const { ensureSetupState, isSetupComplete } = await import("../hooks/lib/setup");
77
183
  const { scaffoldTelos } = await import("../targets/lib");
78
184
 
@@ -82,7 +188,6 @@ async function init() {
82
188
  const isRepo = existsSync(resolve(palPkg(), ".palroot"));
83
189
 
84
190
  if (!isRepo) {
85
- // Package mode — scaffold ~/.pal/
86
191
  log.info(`Creating PAL home at ${home}`);
87
192
  mkdirSync(resolve(home, "telos"), { recursive: true });
88
193
  mkdirSync(resolve(home, "memory"), { recursive: true });
@@ -91,8 +196,7 @@ async function init() {
91
196
  scaffoldTelos();
92
197
  ensureSetupState();
93
198
 
94
- const targets = parseTargets(args);
95
- await install(targets);
199
+ await install(parseTargets(args));
96
200
 
97
201
  console.log("");
98
202
  const state = ensureSetupState();
@@ -101,16 +205,14 @@ async function init() {
101
205
  }
102
206
  }
103
207
 
104
- async function install(targets?: { claude: boolean; opencode: boolean }) {
105
- const t = targets || parseTargets(args);
106
-
107
- if (t.claude) {
208
+ async function install(targets: { claude: boolean; opencode: boolean }) {
209
+ if (targets.claude) {
108
210
  console.log("━━━ Claude Code ━━━");
109
211
  await import("../targets/claude/install");
110
212
  console.log("");
111
213
  }
112
214
 
113
- if (t.opencode) {
215
+ if (targets.opencode) {
114
216
  console.log("━━━ opencode ━━━");
115
217
  await import("../targets/opencode/install");
116
218
  console.log("");
@@ -119,7 +221,7 @@ async function install(targets?: { claude: boolean; opencode: boolean }) {
119
221
  log.success("Done. Existing config was preserved — only new entries were added.");
120
222
  }
121
223
 
122
- async function uninstall() {
224
+ async function uninstall(args: string[]) {
123
225
  const targets = parseTargets(args);
124
226
 
125
227
  if (targets.claude) {
@@ -139,13 +241,13 @@ async function uninstall() {
139
241
  );
140
242
  }
141
243
 
142
- async function exportState() {
244
+ async function exportState(args: string[]) {
143
245
  const { collectExportFiles, exportZip, timestamp } = await import(
144
246
  "../hooks/lib/export"
145
247
  );
146
248
 
147
249
  const dryRun = args.includes("--dry-run");
148
- const pathArg = args.find((a) => !a.startsWith("-") && a !== "export");
250
+ const pathArg = args.find((a) => !a.startsWith("-"));
149
251
  const outputPath = pathArg || resolve(palHome(), `pal-export-${timestamp()}.zip`);
150
252
 
151
253
  if (dryRun) {
@@ -166,14 +268,14 @@ async function exportState() {
166
268
  }
167
269
  }
168
270
 
169
- async function importState() {
170
- const { readdirSync, statSync } = await import("node:fs");
271
+ async function importState(args: string[]) {
272
+ const { statSync } = await import("node:fs");
171
273
  const { createInterface } = await import("node:readline");
172
274
  const AdmZip = (await import("adm-zip")).default;
173
275
 
174
276
  const home = palHome();
175
277
  const dryRun = args.includes("--dry-run");
176
- const pathArg = args.find((a) => !a.startsWith("-") && a !== "import");
278
+ const pathArg = args.find((a) => !a.startsWith("-"));
177
279
 
178
280
  function findLatest(): string | null {
179
281
  const candidates: string[] = [];
@@ -214,7 +316,7 @@ async function importState() {
214
316
  } else {
215
317
  const latest = findLatest();
216
318
  if (!latest) {
217
- log.error("No export or backup files found. Provide a path: pal import <path>");
319
+ log.error("No export or backup files found. Provide a path: pal cli import <path>");
218
320
  process.exit(1);
219
321
  }
220
322
  console.log(`Found: ${latest}`);
@@ -254,13 +356,11 @@ async function importState() {
254
356
  } else {
255
357
  zip.extractAllTo(home, true);
256
358
  console.log(`Imported ${entries.length} files → ${home}`);
257
- log.info("Run 'pal install' to re-register hooks.");
359
+ log.info("Run 'pal cli install' to re-register hooks.");
258
360
  }
259
361
  }
260
362
 
261
363
  async function status() {
262
- const { existsSync, readdirSync, readFileSync } = await import("node:fs");
263
-
264
364
  const home = palHome();
265
365
  const pkg = palPkg();
266
366
  const isRepo = existsSync(resolve(pkg, ".palroot"));
@@ -274,13 +374,11 @@ async function status() {
274
374
  log.info(`Home: ${home}`);
275
375
  console.log("");
276
376
 
277
- // Platform dirs
278
377
  log.info(`Claude: ${platform.claudeDir()}`);
279
378
  log.info(`opencode: ${platform.opencodeDir()}`);
280
379
  log.info(`Agents: ${platform.agentsDir()}`);
281
380
  console.log("");
282
381
 
283
- // Counts
284
382
  const count = (dir: string, ext?: string) => {
285
383
  try {
286
384
  const files = readdirSync(dir);
@@ -298,7 +396,6 @@ async function status() {
298
396
  const agentsDir = resolve(platform.claudeDir(), "agents");
299
397
  log.info(`Agents: ${count(agentsDir, ".md")} installed`);
300
398
 
301
- // Check if hooks are registered
302
399
  const settingsPath = resolve(platform.claudeDir(), "settings.json");
303
400
  try {
304
401
  const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
@@ -309,36 +406,3 @@ async function status() {
309
406
  }
310
407
  console.log("");
311
408
  }
312
-
313
- // ── Dispatch ──
314
-
315
- switch (command) {
316
- case "init":
317
- await init();
318
- break;
319
- case "install":
320
- banner();
321
- await install();
322
- break;
323
- case "uninstall":
324
- await uninstall();
325
- break;
326
- case "export":
327
- await exportState();
328
- break;
329
- case "import":
330
- await importState();
331
- break;
332
- case "status":
333
- await status();
334
- break;
335
- case "--help":
336
- case "-h":
337
- case "help":
338
- showHelp();
339
- break;
340
- default:
341
- if (command) log.error(`Unknown command: ${command}`);
342
- showHelp();
343
- process.exit(command ? 1 : 0);
344
- }
package/bin/pal DELETED
@@ -1,24 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Jarvis — Claude Code wrapper with session summary on exit.
3
- #
4
- # After Claude exits, finds the most recently modified transcript JSONL
5
- # in ~/.claude/projects/ and extracts the sessionId from its last line.
6
-
7
- PAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
8
-
9
- # Run Claude (blocking — keeps the interactive terminal)
10
- claude "$@"
11
- EXIT_CODE=$?
12
-
13
- # Find the most recently modified transcript and extract its session ID
14
- LATEST=$(find "$HOME/.claude/projects" -name '*.jsonl' -type f -print0 2>/dev/null \
15
- | xargs -0 ls -t 2>/dev/null | head -1)
16
-
17
- if [ -n "$LATEST" ]; then
18
- SESSION_ID=$(tail -1 "$LATEST" | python3 -c "import sys,json; print(json.loads(sys.stdin.readline()).get('sessionId',''))" 2>/dev/null)
19
- if [ -n "$SESSION_ID" ]; then
20
- bun run "$PAL_DIR/src/tools/session-summary.ts" -- --session "$SESSION_ID" 2>/dev/null
21
- fi
22
- fi
23
-
24
- exit $EXIT_CODE
package/bin/pal.bat DELETED
@@ -1,8 +0,0 @@
1
- @echo off
2
- REM Jarvis — Claude Code wrapper with session summary on exit.
3
- REM
4
- REM Uses PowerShell to start Claude, capture its PID, read the session ID
5
- REM from %USERPROFILE%\.claude\sessions\<PID>.json, then show a cost
6
- REM summary after Claude exits.
7
-
8
- powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0pal.ps1" %*
package/bin/pal.ps1 DELETED
@@ -1,30 +0,0 @@
1
- # Jarvis — Claude Code wrapper with session summary on exit.
2
- #
3
- # After Claude exits, finds the most recently modified transcript JSONL
4
- # in ~/.claude/projects/ and extracts the sessionId from its last line.
5
-
6
- $palDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
7
-
8
- # Run Claude (blocking — keeps the interactive terminal)
9
- & claude @args
10
- $exitCode = $LASTEXITCODE
11
-
12
- # Find the most recently modified transcript and extract its session ID
13
- $latest = Get-ChildItem "$env:USERPROFILE\.claude\projects\*\*.jsonl" -ErrorAction SilentlyContinue |
14
- Sort-Object LastWriteTime -Descending |
15
- Select-Object -First 1
16
-
17
- if ($latest) {
18
- $lastLine = Get-Content $latest.FullName -Tail 1 -ErrorAction SilentlyContinue
19
- if ($lastLine) {
20
- try {
21
- $sessionId = ($lastLine | ConvertFrom-Json).sessionId
22
- if ($sessionId) {
23
- $summaryScript = Join-Path $palDir "src" "tools" "session-summary.ts"
24
- & bun run $summaryScript -- --session $sessionId 2>$null
25
- }
26
- } catch {}
27
- }
28
- }
29
-
30
- exit $exitCode