portable-agent-layer 0.104.1 → 0.105.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.104.1",
3
+ "version": "0.105.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,6 @@ import {
4
4
  fmt,
5
5
  fmtCost,
6
6
  grandBucket,
7
- inferenceLabel,
8
7
  modelLabel,
9
8
  modelsByCost,
10
9
  projectsByCost,
@@ -100,7 +99,7 @@ function projectSection(style: Style, agents: AgentReport[]): string[] {
100
99
 
101
100
  function inferenceSections(style: Style, data: UsageData): string[] {
102
101
  return usedInferenceModels(data.pal).flatMap(([model, buckets]) => [
103
- subtle(style, "PAL inference", inferenceLabel(model)),
102
+ subtle(style, "PAL inference", modelLabel(model)),
104
103
  ...windowRows(style, buckets),
105
104
  "",
106
105
  ]);
@@ -6,7 +6,7 @@
6
6
  * never touched.
7
7
  */
8
8
 
9
- import { copyFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { mkdirSync } from "node:fs";
10
10
  import { dirname, resolve } from "node:path";
11
11
  import { skillsDirOf } from "../../hooks/lib/agent-registry";
12
12
  import { ensureAntigravityRule } from "../../hooks/lib/claude-md";
@@ -23,6 +23,7 @@ import {
23
23
  mergeSettings,
24
24
  nativeAgentsDir,
25
25
  readJson,
26
+ replaceJsonConfig,
26
27
  writeJson,
27
28
  } from "../lib";
28
29
 
@@ -49,8 +50,7 @@ log.success("PAL hooks → hooks.json");
49
50
 
50
51
  const SETTINGS = platform.antigravitySettings();
51
52
  mkdirSync(dirname(SETTINGS), { recursive: true });
52
- if (existsSync(SETTINGS)) copyFileSync(SETTINGS, `${SETTINGS}.bak.${Date.now()}`);
53
- writeJson(
53
+ replaceJsonConfig(
54
54
  SETTINGS,
55
55
  addStatuslineConfig(
56
56
  mergeSettings(
@@ -5,14 +5,7 @@
5
5
  * removing the folder never walks into the skills they point at.
6
6
  */
7
7
 
8
- import {
9
- copyFileSync,
10
- existsSync,
11
- lstatSync,
12
- readdirSync,
13
- rmSync,
14
- unlinkSync,
15
- } from "node:fs";
8
+ import { existsSync, lstatSync, readdirSync, rmSync, unlinkSync } from "node:fs";
16
9
  import { resolve } from "node:path";
17
10
  import { skillsDirOf } from "../../hooks/lib/agent-registry";
18
11
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
@@ -22,8 +15,8 @@ import {
22
15
  readJson,
23
16
  removeStatusline,
24
17
  removeStatuslineConfig,
18
+ replaceJsonConfig,
25
19
  unmergeSettings,
26
- writeJson,
27
20
  } from "../lib";
28
21
 
29
22
  function unlinkSkillLinks(dir: string): string[] {
@@ -47,12 +40,11 @@ if (existsSync(PLUGIN_DIR)) {
47
40
 
48
41
  const SETTINGS = platform.antigravitySettings();
49
42
  if (existsSync(SETTINGS)) {
50
- copyFileSync(SETTINGS, `${SETTINGS}.bak.${Date.now()}`);
51
43
  const template = loadSettingsTemplate(
52
44
  assets.antigravitySettingsTemplate(),
53
45
  palPkg().replaceAll("\\", "/")
54
46
  );
55
- writeJson(
47
+ replaceJsonConfig(
56
48
  SETTINGS,
57
49
  removeStatuslineConfig(
58
50
  unmergeSettings(readJson(SETTINGS, {}), template),
@@ -4,7 +4,7 @@
4
4
  * Copies skills, agents, and PAL docs. Generates CLAUDE.md from TELOS.
5
5
  */
6
6
 
7
- import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { assets, palHome, palPkg, platform } from "../../hooks/lib/paths";
10
10
  import { identity, raw as readPalSettings } from "../../hooks/lib/settings";
@@ -21,7 +21,7 @@ import {
21
21
  log,
22
22
  mergeSettings,
23
23
  readJson,
24
- writeJson,
24
+ replaceJsonConfig,
25
25
  } from "../lib";
26
26
 
27
27
  const PKG_ROOT = palPkg().replaceAll("\\", "/");
@@ -35,11 +35,6 @@ if (!existsSync(SETTINGS)) {
35
35
  log.info("Created new settings.json");
36
36
  }
37
37
 
38
- // --- Backup ---
39
- const backup = `${SETTINGS}.bak.${Date.now()}`;
40
- copyFileSync(SETTINGS, backup);
41
- log.info("Backed up settings.json");
42
-
43
38
  // --- Load template and merge into existing settings ---
44
39
  const template = loadSettingsTemplate(assets.claudeSettingsTemplate(), PKG_ROOT);
45
40
  const existing = readJson<Record<string, unknown>>(SETTINGS, {});
@@ -58,7 +53,7 @@ if (attribution?.decided) {
58
53
  });
59
54
  }
60
55
 
61
- writeJson(SETTINGS, merged);
56
+ replaceJsonConfig(SETTINGS, merged);
62
57
  log.success("Merged PAL settings into settings.json");
63
58
 
64
59
  // --- Copy skills ---
@@ -3,7 +3,7 @@
3
3
  * Removes exactly what the settings template added, plus skills, agents, and PAL docs.
4
4
  */
5
5
 
6
- import { copyFileSync, existsSync, unlinkSync } from "node:fs";
6
+ import { existsSync, unlinkSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
9
9
  import {
@@ -15,8 +15,8 @@ import {
15
15
  removeSkills,
16
16
  removeStatusline,
17
17
  removeStatuslineConfig,
18
+ replaceJsonConfig,
18
19
  unmergeSettings,
19
- writeJson,
20
20
  } from "../lib";
21
21
 
22
22
  const PKG_ROOT = palPkg().replaceAll("\\", "/");
@@ -28,10 +28,6 @@ if (!existsSync(SETTINGS)) {
28
28
  process.exit(0);
29
29
  }
30
30
 
31
- // --- Backup ---
32
- copyFileSync(SETTINGS, `${SETTINGS}.bak.${Date.now()}`);
33
- log.info("Backed up settings.json");
34
-
35
31
  // --- Load template and unmerge from existing settings ---
36
32
  const template = loadSettingsTemplate(assets.claudeSettingsTemplate(), PKG_ROOT);
37
33
  const existing = readJson<Record<string, unknown>>(SETTINGS, {});
@@ -40,7 +36,7 @@ let cleaned = unmergeSettings(existing, template);
40
36
  // Remove statusLine config
41
37
  cleaned = removeStatuslineConfig(cleaned);
42
38
 
43
- writeJson(SETTINGS, cleaned);
39
+ replaceJsonConfig(SETTINGS, cleaned);
44
40
  log.success("Removed PAL settings from settings.json");
45
41
 
46
42
  // --- Remove PAL skills ---
@@ -4,13 +4,7 @@
4
4
  * Symlinks skills, renders agents to TOML. Ensures AGENTS.md symlink via regenerateIfNeeded().
5
5
  */
6
6
 
7
- import {
8
- copyFileSync,
9
- existsSync,
10
- mkdirSync,
11
- readFileSync,
12
- writeFileSync,
13
- } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
8
  import { resolve } from "node:path";
15
9
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
16
10
  import {
@@ -24,7 +18,8 @@ import {
24
18
  mergeCodexHooks,
25
19
  mergeCodexRules,
26
20
  readJson,
27
- writeJson,
21
+ replaceConfig,
22
+ replaceJsonConfig,
28
23
  } from "../lib";
29
24
 
30
25
  /**
@@ -74,27 +69,18 @@ const RULES_FILE = resolve(CODEX_DIR, "rules", "default.rules");
74
69
  mkdirSync(CODEX_DIR, { recursive: true });
75
70
 
76
71
  // --- Merge hooks ---
77
- if (existsSync(HOOKS_FILE)) {
78
- copyFileSync(HOOKS_FILE, `${HOOKS_FILE}.bak.${Date.now()}`);
79
- log.info("Backed up hooks.json");
80
- }
81
-
82
72
  const template = loadCodexHooksTemplate(assets.codexHooksTemplate(), PKG_ROOT);
83
73
  const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
84
74
  const merged = mergeCodexHooks(existing, template);
85
75
 
86
- writeJson(HOOKS_FILE, merged);
76
+ replaceJsonConfig(HOOKS_FILE, merged);
87
77
  log.success(`Merged PAL hooks into ${HOOKS_FILE}`);
88
78
 
89
79
  // --- Merge allowlist rules ---
90
80
  mkdirSync(resolve(CODEX_DIR, "rules"), { recursive: true });
91
- if (existsSync(RULES_FILE)) {
92
- copyFileSync(RULES_FILE, `${RULES_FILE}.bak.${Date.now()}`);
93
- log.info("Backed up rules/default.rules");
94
- }
95
81
  const rulesTemplate = loadCodexRulesTemplate(assets.codexRulesTemplate());
96
82
  const existingRules = existsSync(RULES_FILE) ? readFileSync(RULES_FILE, "utf-8") : "";
97
- writeFileSync(RULES_FILE, mergeCodexRules(existingRules, rulesTemplate), "utf-8");
83
+ replaceConfig(RULES_FILE, mergeCodexRules(existingRules, rulesTemplate));
98
84
  log.success("Merged PAL allowlist rules into ~/.codex/rules/default.rules");
99
85
 
100
86
  // --- Symlink skills to ~/.codex/skills/ ---
@@ -4,7 +4,7 @@
4
4
  * Removes PAL skill symlinks and PAL agents.
5
5
  */
6
6
 
7
- import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
10
10
  import {
@@ -14,9 +14,10 @@ import {
14
14
  removeAgentsFromCodex,
15
15
  removeCodexStatuslineConfig,
16
16
  removeSkills,
17
+ replaceConfig,
18
+ replaceJsonConfig,
17
19
  unmergeCodexHooks,
18
20
  unmergeCodexRules,
19
- writeJson,
20
21
  } from "../lib";
21
22
 
22
23
  /**
@@ -54,14 +55,11 @@ const RULES_FILE = resolve(CODEX_DIR, "rules", "default.rules");
54
55
 
55
56
  // --- Remove PAL hooks from hooks.json ---
56
57
  if (existsSync(HOOKS_FILE)) {
57
- copyFileSync(HOOKS_FILE, `${HOOKS_FILE}.bak.${Date.now()}`);
58
- log.info("Backed up hooks.json");
59
-
60
58
  const template = loadCodexHooksTemplate(assets.codexHooksTemplate(), PKG_ROOT);
61
59
  const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
62
60
  const cleaned = unmergeCodexHooks(existing, template);
63
61
 
64
- writeJson(HOOKS_FILE, cleaned);
62
+ replaceJsonConfig(HOOKS_FILE, cleaned);
65
63
  log.success("Removed PAL hooks from ~/.codex/hooks.json");
66
64
  } else {
67
65
  log.info("No hooks.json found, nothing to do");
@@ -69,11 +67,8 @@ if (existsSync(HOOKS_FILE)) {
69
67
 
70
68
  // --- Remove PAL allowlist rules from default.rules ---
71
69
  if (existsSync(RULES_FILE)) {
72
- copyFileSync(RULES_FILE, `${RULES_FILE}.bak.${Date.now()}`);
73
- log.info("Backed up rules/default.rules");
74
-
75
70
  const cleanedRules = unmergeCodexRules(readFileSync(RULES_FILE, "utf-8"));
76
- writeFileSync(RULES_FILE, cleanedRules, "utf-8");
71
+ replaceConfig(RULES_FILE, cleanedRules);
77
72
  log.success("Removed PAL allowlist rules from ~/.codex/rules/default.rules");
78
73
  } else {
79
74
  log.info("No default.rules found, nothing to do");
@@ -4,7 +4,7 @@
4
4
  * Symlinks skills. Generates AGENTS.md.
5
5
  */
6
6
 
7
- import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
10
10
  import {
@@ -17,7 +17,7 @@ import {
17
17
  log,
18
18
  mergeCursorHooks,
19
19
  readJson,
20
- writeJson,
20
+ replaceJsonConfig,
21
21
  } from "../lib";
22
22
 
23
23
  const PKG_ROOT = palPkg().replaceAll("\\", "/");
@@ -29,17 +29,11 @@ const CLI_CONFIG = resolve(CURSOR_DIR, "cli-config.json");
29
29
  mkdirSync(CURSOR_DIR, { recursive: true });
30
30
 
31
31
  // --- Merge hooks ---
32
- if (existsSync(HOOKS_FILE)) {
33
- const backup = `${HOOKS_FILE}.bak.${Date.now()}`;
34
- copyFileSync(HOOKS_FILE, backup);
35
- log.info("Backed up hooks.json");
36
- }
37
-
38
32
  const template = loadCursorHooksTemplate(assets.cursorHooksTemplate(), PKG_ROOT);
39
33
  const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
40
34
  const merged = mergeCursorHooks(existing, template);
41
35
 
42
- writeJson(HOOKS_FILE, merged);
36
+ replaceJsonConfig(HOOKS_FILE, merged);
43
37
  log.success(`Merged PAL hooks into ${HOOKS_FILE}`);
44
38
 
45
39
  // --- Symlink skills to ~/.cursor/skills/ ---
@@ -56,12 +50,9 @@ copyStatusline("cursor");
56
50
  if (!existsSync(CLI_CONFIG)) {
57
51
  writeFileSync(CLI_CONFIG, "{}\n", "utf-8");
58
52
  log.info("Created new cli-config.json");
59
- } else {
60
- copyFileSync(CLI_CONFIG, `${CLI_CONFIG}.bak.${Date.now()}`);
61
- log.info("Backed up cli-config.json");
62
53
  }
63
54
  const cliConfig = readJson<Record<string, unknown>>(CLI_CONFIG, {});
64
- writeJson(CLI_CONFIG, addStatuslineConfig(cliConfig, "cursor"));
55
+ replaceJsonConfig(CLI_CONFIG, addStatuslineConfig(cliConfig, "cursor"));
65
56
  log.success("Merged statusLine into cli-config.json");
66
57
 
67
58
  log.info(
@@ -4,7 +4,7 @@
4
4
  * Removes PAL skill symlinks.
5
5
  */
6
6
 
7
- import { copyFileSync, existsSync } from "node:fs";
7
+ import { existsSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
10
10
  import {
@@ -17,8 +17,8 @@ import {
17
17
  removeSkills,
18
18
  removeStatusline,
19
19
  removeStatuslineConfig,
20
+ replaceJsonConfig,
20
21
  unmergeCursorHooks,
21
- writeJson,
22
22
  } from "../lib";
23
23
 
24
24
  const PKG_ROOT = palPkg().replaceAll("\\", "/");
@@ -28,15 +28,11 @@ const CLI_CONFIG = resolve(CURSOR_DIR, "cli-config.json");
28
28
 
29
29
  // --- Remove PAL hooks from hooks.json ---
30
30
  if (existsSync(HOOKS_FILE)) {
31
- // Backup before modifying
32
- copyFileSync(HOOKS_FILE, `${HOOKS_FILE}.bak.${Date.now()}`);
33
- log.info("Backed up hooks.json");
34
-
35
31
  const template = loadCursorHooksTemplate(assets.cursorHooksTemplate(), PKG_ROOT);
36
32
  const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
37
33
  const cleaned = unmergeCursorHooks(existing, template);
38
34
 
39
- writeJson(HOOKS_FILE, cleaned);
35
+ replaceJsonConfig(HOOKS_FILE, cleaned);
40
36
  log.success("Removed PAL hooks from hooks.json");
41
37
  } else {
42
38
  log.info("No hooks.json found, nothing to do");
@@ -62,10 +58,8 @@ removePalDocs();
62
58
 
63
59
  // --- Remove statusline script and cli-config statusLine ---
64
60
  if (existsSync(CLI_CONFIG)) {
65
- copyFileSync(CLI_CONFIG, `${CLI_CONFIG}.bak.${Date.now()}`);
66
- log.info("Backed up cli-config.json");
67
61
  const cliConfig = readJson<Record<string, unknown>>(CLI_CONFIG, {});
68
- writeJson(CLI_CONFIG, removeStatuslineConfig(cliConfig, "cursor"));
62
+ replaceJsonConfig(CLI_CONFIG, removeStatuslineConfig(cliConfig, "cursor"));
69
63
  log.success("Removed PAL statusLine from cli-config.json");
70
64
  } else {
71
65
  log.info("No cli-config.json found, skipping statusLine removal");
@@ -17,7 +17,7 @@ import {
17
17
  writeFileSync,
18
18
  } from "node:fs";
19
19
  import { homedir } from "node:os";
20
- import { dirname, isAbsolute, resolve, sep } from "node:path";
20
+ import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
21
21
  import { createStyle, PALETTE, type Rgb } from "../cli/ui/style";
22
22
  import {
23
23
  AGENT_NAMES,
@@ -113,8 +113,41 @@ export function readJson<T = Record<string, unknown>>(path: string, fallback: T)
113
113
  }
114
114
  }
115
115
 
116
+ const jsonText = (data: unknown) => `${JSON.stringify(data, null, 2)}\n`;
117
+
116
118
  export function writeJson(path: string, data: unknown): void {
117
- writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
119
+ writeFileSync(path, jsonText(data), "utf-8");
120
+ }
121
+
122
+ // --- Config backups ---
123
+
124
+ const KEPT_BACKUPS = 5;
125
+
126
+ function backupsOf(path: string): string[] {
127
+ const prefix = `${basename(path)}.bak.`;
128
+ const stampOf = (name: string) => Number(name.slice(prefix.length));
129
+ return readdirSync(dirname(path))
130
+ .filter((name) => name.startsWith(prefix) && /^\d+$/.test(name.slice(prefix.length)))
131
+ .sort((a, b) => stampOf(b) - stampOf(a))
132
+ .map((name) => resolve(dirname(path), name));
133
+ }
134
+
135
+ function pruneBackups(path: string): void {
136
+ for (const old of backupsOf(path).slice(KEPT_BACKUPS)) unlinkSync(old);
137
+ }
138
+
139
+ /** Writes a config only when its content changes, backing up what it replaces. */
140
+ export function replaceConfig(path: string, content: string): void {
141
+ const current = existsSync(path) ? readFileSync(path, "utf-8") : null;
142
+ if (current !== content) {
143
+ if (current !== null) copyFileSync(path, `${path}.bak.${Date.now()}`);
144
+ writeFileSync(path, content, "utf-8");
145
+ }
146
+ pruneBackups(path);
147
+ }
148
+
149
+ export function replaceJsonConfig(path: string, data: unknown): void {
150
+ replaceConfig(path, jsonText(data));
118
151
  }
119
152
 
120
153
  // --- Git attribution ---
@@ -105,9 +105,9 @@ function byCost<T>(entries: [string, T][], costOf: (value: T) => number): [strin
105
105
  return entries.sort((a, b) => costOf(b[1]) - costOf(a[1]));
106
106
  }
107
107
 
108
- /** Drops the vendor's prefix and any provider path, so `accounts/x/models/kimi` reads `kimi`. */
108
+ /** Drops the vendor's prefix, any provider path and a release date, so `accounts/x/models/kimi` reads `kimi`. */
109
109
  export function modelLabel(model: string): string {
110
- return (model.split("/").at(-1) ?? model).replace("claude-", "");
110
+ return (model.split("/").at(-1) ?? model).replace("claude-", "").replace(/-\d{8}$/, "");
111
111
  }
112
112
 
113
113
  export const usedAgents = (agents: AgentReport[]) =>
@@ -160,18 +160,12 @@ function byProjectLines(agents: AgentReport[]): string[] {
160
160
  ];
161
161
  }
162
162
 
163
- export function inferenceLabel(model: string): string {
164
- if (model.includes("haiku")) return "Haiku";
165
- if (model.includes("sonnet")) return "Sonnet";
166
- return model.replace("claude-", "");
167
- }
168
-
169
163
  export const usedInferenceModels = (pal: PalInferenceUsage) =>
170
164
  Object.entries(pal.byModel).filter(([, buckets]) => buckets.total.calls > 0);
171
165
 
172
166
  function palInferenceLines(pal: PalInferenceUsage): string[] {
173
167
  return usedInferenceModels(pal).flatMap(([model, buckets]) => [
174
- `\n PAL Inference (${inferenceLabel(model)})\n`,
168
+ `\n PAL Inference (${modelLabel(model)})\n`,
175
169
  ...windowLines(buckets),
176
170
  ]);
177
171
  }