portable-agent-layer 0.6.2 → 0.8.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
@@ -77,6 +77,7 @@ pal cli status # check your setup
77
77
  | `pal cli init` | Scaffold PAL home directory and install hooks |
78
78
  | `pal cli install` | Register hooks/skills for targets |
79
79
  | `pal cli uninstall` | Remove hooks/skills for targets |
80
+ | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks |
80
81
  | `pal cli export` | Export user state (telos, memory) to a zip |
81
82
  | `pal cli import` | Import user state from a zip |
82
83
  | `pal cli status` | Show current PAL configuration |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.6.2",
3
+ "version": "0.8.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
@@ -10,6 +10,7 @@
10
10
  * init Scaffold PAL home, install hooks for all targets
11
11
  * install [--claude] [--opencode] Register hooks/skills for targets
12
12
  * uninstall [--claude] [--opencode] Remove hooks/skills for targets
13
+ * update Update PAL (git pull or npm update)
13
14
  * export [path] [--dry-run] Export user state to zip
14
15
  * import [path] [--dry-run] Import user state from zip
15
16
  * status Show current PAL configuration
@@ -122,6 +123,18 @@ async function session(sessionArgs: string[]) {
122
123
  // Silently ignore summary errors
123
124
  }
124
125
 
126
+ // Check for updates and display notice
127
+ try {
128
+ const { checkForUpdate, getUpdateNotice } = await import(
129
+ "../hooks/handlers/update-check"
130
+ );
131
+ await checkForUpdate();
132
+ const notice = getUpdateNotice();
133
+ if (notice) console.log(`\n${notice}`);
134
+ } catch {
135
+ // Non-critical
136
+ }
137
+
125
138
  process.exit(exitCode);
126
139
  }
127
140
 
@@ -145,6 +158,9 @@ async function runCli(command: string | undefined, args: string[]) {
145
158
  case "import":
146
159
  await importState(args);
147
160
  break;
161
+ case "update":
162
+ await update();
163
+ break;
148
164
  case "status":
149
165
  await status();
150
166
  break;
@@ -184,6 +200,7 @@ function showHelp() {
184
200
  pal cli init [--claude] [--opencode] Scaffold and install (default: all)
185
201
  pal cli install [--claude] [--opencode] Register hooks for targets
186
202
  pal cli uninstall [--claude] [--opencode] Remove hooks for targets
203
+ pal cli update Update PAL (git pull or npm update)
187
204
  pal cli export [path] [--dry-run] Export state to zip
188
205
  pal cli import [path] [--dry-run] Import state from zip
189
206
  pal cli status Show PAL configuration
@@ -562,6 +579,45 @@ async function importState(args: string[]) {
562
579
  }
563
580
  }
564
581
 
582
+ async function update() {
583
+ const { checkForUpdate } = await import("../hooks/handlers/update-check");
584
+ const result = await checkForUpdate(true);
585
+
586
+ log.info(`Current: ${result.current} (${result.mode} mode)`);
587
+
588
+ if (!result.available) {
589
+ log.success("Already up to date.");
590
+ return;
591
+ }
592
+
593
+ log.info(`Available: ${result.latest}`);
594
+
595
+ const pkg = palPkg();
596
+ if (result.mode === "repo") {
597
+ log.info("Pulling updates...");
598
+ const pull = spawnSync("git", ["pull", "--ff-only"], { cwd: pkg, stdio: "inherit" });
599
+ if (pull.status !== 0) {
600
+ log.error("git pull failed. You may have local changes — try pulling manually.");
601
+ process.exit(1);
602
+ }
603
+ } else {
604
+ log.info("Updating via npm...");
605
+ const up = spawnSync("bun", ["update", "-g", "portable-agent-layer"], {
606
+ stdio: "inherit",
607
+ });
608
+ if (up.status !== 0) {
609
+ log.error("Update failed. Try: bun update -g portable-agent-layer");
610
+ process.exit(1);
611
+ }
612
+ }
613
+
614
+ const newPkg = JSON.parse(readFileSync(resolve(pkg, "package.json"), "utf-8"));
615
+ log.success(`Updated: ${result.current} → ${newPkg.version}`);
616
+
617
+ log.info("Reinstalling...");
618
+ await install(resolveTargets([]));
619
+ }
620
+
565
621
  async function status() {
566
622
  const home = palHome();
567
623
  const pkg = palPkg();
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Update checker — detects if a newer version of PAL is available.
3
+ *
4
+ * Repo mode (.palroot exists): git fetch + compare HEAD vs origin/main
5
+ * Package mode: fetch npm registry for latest version vs installed
6
+ *
7
+ * Caches result in state/update-available.json. Checked at most once per hour.
8
+ */
9
+
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { logDebug } from "../lib/log";
13
+ import { ensureDir, palPkg, paths } from "../lib/paths";
14
+
15
+ interface UpdateCache {
16
+ checkedAt: string;
17
+ available: boolean;
18
+ current: string;
19
+ latest: string;
20
+ mode: "repo" | "package";
21
+ }
22
+
23
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
24
+
25
+ function cachePath(): string {
26
+ return resolve(ensureDir(paths.state()), "update-available.json");
27
+ }
28
+
29
+ function readCache(): UpdateCache | null {
30
+ try {
31
+ const fp = cachePath();
32
+ if (!existsSync(fp)) return null;
33
+ const cache = JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache;
34
+ if (Date.now() - new Date(cache.checkedAt).getTime() < CACHE_TTL_MS) return cache;
35
+ return null; // expired
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function writeCache(cache: UpdateCache): void {
42
+ try {
43
+ writeFileSync(cachePath(), JSON.stringify(cache, null, 2), "utf-8");
44
+ } catch {
45
+ /* non-critical */
46
+ }
47
+ }
48
+
49
+ function isRepoMode(): boolean {
50
+ return existsSync(resolve(palPkg(), ".palroot"));
51
+ }
52
+
53
+ function getInstalledVersion(): string {
54
+ try {
55
+ const pkg = JSON.parse(readFileSync(resolve(palPkg(), "package.json"), "utf-8"));
56
+ return pkg.version || "0.0.0";
57
+ } catch {
58
+ return "0.0.0";
59
+ }
60
+ }
61
+
62
+ async function checkRepo(): Promise<UpdateCache> {
63
+ const repoDir = palPkg();
64
+ const current = getInstalledVersion();
65
+
66
+ try {
67
+ const fetch = Bun.spawn(["git", "fetch", "--quiet"], {
68
+ cwd: repoDir,
69
+ stdout: "ignore",
70
+ stderr: "ignore",
71
+ });
72
+ await fetch.exited;
73
+
74
+ const local = Bun.spawn(["git", "rev-parse", "HEAD"], {
75
+ cwd: repoDir,
76
+ stdout: "pipe",
77
+ stderr: "ignore",
78
+ });
79
+ const localHash = (await new Response(local.stdout).text()).trim();
80
+
81
+ const remote = Bun.spawn(["git", "rev-parse", "origin/main"], {
82
+ cwd: repoDir,
83
+ stdout: "pipe",
84
+ stderr: "ignore",
85
+ });
86
+ const remoteHash = (await new Response(remote.stdout).text()).trim();
87
+
88
+ const available = localHash !== remoteHash && remoteHash.length > 0;
89
+
90
+ // Get remote version from package.json on origin/main
91
+ let latest = current;
92
+ if (available) {
93
+ try {
94
+ const show = Bun.spawn(["git", "show", "origin/main:package.json"], {
95
+ cwd: repoDir,
96
+ stdout: "pipe",
97
+ stderr: "ignore",
98
+ });
99
+ const remotePkg = JSON.parse(await new Response(show.stdout).text());
100
+ latest = remotePkg.version || current;
101
+ } catch {
102
+ latest = `${remoteHash.slice(0, 7)} (ahead)`;
103
+ }
104
+ }
105
+
106
+ return {
107
+ checkedAt: new Date().toISOString(),
108
+ available,
109
+ current,
110
+ latest,
111
+ mode: "repo",
112
+ };
113
+ } catch {
114
+ return {
115
+ checkedAt: new Date().toISOString(),
116
+ available: false,
117
+ current,
118
+ latest: current,
119
+ mode: "repo",
120
+ };
121
+ }
122
+ }
123
+
124
+ async function checkNpm(): Promise<UpdateCache> {
125
+ const current = getInstalledVersion();
126
+
127
+ try {
128
+ const response = await fetch(
129
+ "https://registry.npmjs.org/portable-agent-layer/latest",
130
+ { signal: AbortSignal.timeout(5000) }
131
+ );
132
+
133
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
134
+ const data = (await response.json()) as { version?: string };
135
+ const latest = data.version || current;
136
+
137
+ return {
138
+ checkedAt: new Date().toISOString(),
139
+ available: latest !== current,
140
+ current,
141
+ latest,
142
+ mode: "package",
143
+ };
144
+ } catch {
145
+ return {
146
+ checkedAt: new Date().toISOString(),
147
+ available: false,
148
+ current,
149
+ latest: current,
150
+ mode: "package",
151
+ };
152
+ }
153
+ }
154
+
155
+ /** Run update check. Caches result. Use force=true to skip cache (e.g. for `pal cli update`). */
156
+ export async function checkForUpdate(force = false): Promise<UpdateCache> {
157
+ if (!force) {
158
+ const cached = readCache();
159
+ if (cached) {
160
+ logDebug("update-check", "Using cached result");
161
+ return cached;
162
+ }
163
+ }
164
+
165
+ const result = isRepoMode() ? await checkRepo() : await checkNpm();
166
+ writeCache(result);
167
+
168
+ if (result.available) {
169
+ logDebug(
170
+ "update-check",
171
+ `Update available: ${result.current} → ${result.latest} (${result.mode})`
172
+ );
173
+ }
174
+
175
+ return result;
176
+ }
177
+
178
+ /** Read cached update status for greeting display. Returns null if no update. */
179
+ export function getUpdateNotice(): string | null {
180
+ try {
181
+ const fp = cachePath();
182
+ if (!existsSync(fp)) return null;
183
+ const cache = JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache;
184
+ if (!cache.available) return null;
185
+
186
+ if (cache.mode === "repo") {
187
+ return `📦 Update available: ${cache.current} → ${cache.latest} (git pull)`;
188
+ }
189
+ return `📦 Update available: ${cache.current} → ${cache.latest} (bun update -g portable-agent-layer)`;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
@@ -30,6 +30,7 @@ export const HOOK_MANAGED_FILES = [
30
30
  "pending-failure.json",
31
31
  "token-usage.jsonl",
32
32
  "graduated.json",
33
+ "update-available.json",
33
34
  ];
34
35
 
35
36
  /** Hook-managed directories — AI must not write to or delete from these */