portable-agent-layer 0.15.1 → 0.16.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
@@ -90,7 +90,8 @@ pal cli status # check your setup
90
90
  ```bash
91
91
  pal cli install --claude # Claude Code only
92
92
  pal cli install --opencode # opencode only
93
- pal cli install # both (default)
93
+ pal cli install --cursor # Cursor only
94
+ pal cli install # all available (default)
94
95
  ```
95
96
 
96
97
  ---
@@ -112,6 +113,7 @@ pal cli install # both (default)
112
113
  | `PAL_PKG` | Override package root |
113
114
  | `PAL_CLAUDE_DIR` | Override Claude config dir (default: `~/.claude`) |
114
115
  | `PAL_OPENCODE_DIR` | Override opencode config dir (default: `~/.config/opencode`) |
116
+ | `PAL_CURSOR_DIR` | Override Cursor config dir (default: `~/.cursor`) |
115
117
  | `PAL_AGENTS_DIR` | Override agents dir (default: `~/.agents`) |
116
118
 
117
119
  ---
@@ -19,7 +19,7 @@ Your first output MUST be the mode header. No freeform output. No skipping this
19
19
  For greetings and brief acknowledgments only.
20
20
 
21
21
  ```
22
- ══════════════ PAL ══════════════
22
+ ══════ PAL ══════
23
23
  🗣️ {{IDENTITY_NAME}}: [response]
24
24
  ```
25
25
 
@@ -28,7 +28,7 @@ For greetings and brief acknowledgments only.
28
28
  FOR: Simple tasks that won't take much effort or time. More advanced tasks use ALGORITHM below.
29
29
 
30
30
  ```
31
- ══════════════ PAL | NATIVE ══════════════
31
+ ══════ PAL | NATIVE ══════
32
32
  🗒️ TASK: [brief description]
33
33
  [work]
34
34
  🔄 ITERATION on: [16 words of context if this is a follow-up]
@@ -47,7 +47,7 @@ FOR: Multi-step, complex, or difficult work. Troubleshooting, debugging, buildin
47
47
  **MANDATORY FIRST ACTION:** Read `~/.agents/PAL/ALGORITHM.md` and follow its instructions exactly.
48
48
 
49
49
  Start your response with the following header in this mode:
50
- ══════════════ PAL | ALGORITHM ══════════════
50
+ ══════ PAL | ALGORITHM ══════
51
51
 
52
52
  ---
53
53
 
@@ -0,0 +1,33 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "sessionStart": [
5
+ {
6
+ "type": "command",
7
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
8
+ }
9
+ ],
10
+ "beforeSubmitPrompt": [
11
+ {
12
+ "type": "command",
13
+ "command": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
14
+ }
15
+ ],
16
+ "preToolUse": [
17
+ {
18
+ "type": "command",
19
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
20
+ },
21
+ {
22
+ "type": "command",
23
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
24
+ }
25
+ ],
26
+ "stop": [
27
+ {
28
+ "type": "command",
29
+ "command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
30
+ }
31
+ ]
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.15.1",
3
+ "version": "0.16.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
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * Admin commands (pal cli ...):
10
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
11
+ * install [--claude] [--opencode] [--cursor] Register hooks/skills for targets
12
+ * uninstall [--claude] [--opencode] [--cursor] Remove hooks/skills for targets
13
13
  * update Update PAL (git pull or npm update)
14
14
  * export [path] [--dry-run] Export user state to zip
15
15
  * import [path] [--dry-run] Import user state from zip
@@ -197,9 +197,9 @@ function showHelp() {
197
197
  pal cli <command> [options] Admin commands
198
198
 
199
199
  Admin commands:
200
- pal cli init [--claude] [--opencode] Scaffold and install (default: all)
201
- pal cli install [--claude] [--opencode] Register hooks for targets
202
- pal cli uninstall [--claude] [--opencode] Remove hooks for targets
200
+ pal cli init [--claude] [--opencode] [--cursor] Scaffold and install (default: all)
201
+ pal cli install [--claude] [--opencode] [--cursor] Register hooks for targets
202
+ pal cli uninstall [--claude] [--opencode] [--cursor] Remove hooks for targets
203
203
  pal cli update Update PAL (git pull or npm update)
204
204
  pal cli export [path] [--dry-run] Export state to zip
205
205
  pal cli import [path] [--dry-run] Import state from zip
@@ -211,6 +211,7 @@ function showHelp() {
211
211
  PAL_PKG Override package root
212
212
  PAL_CLAUDE_DIR Override Claude config dir (default: ~/.claude)
213
213
  PAL_OPENCODE_DIR Override opencode config dir (default: ~/.config/opencode)
214
+ PAL_CURSOR_DIR Override Cursor config dir (default: ~/.cursor)
214
215
  PAL_AGENTS_DIR Override agents dir (default: ~/.agents)
215
216
  `);
216
217
  }
@@ -218,30 +219,35 @@ function showHelp() {
218
219
  function parseTargets(args: string[]): {
219
220
  claude: boolean;
220
221
  opencode: boolean;
222
+ cursor: boolean;
221
223
  } {
222
224
  let claude = false;
223
225
  let opencode = false;
226
+ let cursor = false;
224
227
  for (const arg of args) {
225
228
  if (arg === "--claude") claude = true;
226
229
  else if (arg === "--opencode") opencode = true;
230
+ else if (arg === "--cursor") cursor = true;
227
231
  else if (arg === "--all") {
228
232
  claude = true;
229
233
  opencode = true;
234
+ cursor = true;
230
235
  }
231
236
  }
232
- if (!claude && !opencode) return { claude: true, opencode: true };
233
- return { claude, opencode };
237
+ if (!claude && !opencode && !cursor)
238
+ return { claude: true, opencode: true, cursor: true };
239
+ return { claude, opencode, cursor };
234
240
  }
235
241
 
236
242
  /** Resolve targets against available agents. Errors if explicitly requested but missing. */
237
243
  function resolveTargets(
238
244
  args: string[],
239
245
  health?: DoctorResult
240
- ): { claude: boolean; opencode: boolean } {
246
+ ): { claude: boolean; opencode: boolean; cursor: boolean } {
241
247
  const requested = parseTargets(args);
242
248
  const h = health || doctor(true);
243
249
  const explicit = args.some(
244
- (a) => a === "--claude" || a === "--opencode" || a === "--all"
250
+ (a) => a === "--claude" || a === "--opencode" || a === "--cursor" || a === "--all"
245
251
  );
246
252
 
247
253
  if (explicit) {
@@ -254,6 +260,10 @@ function resolveTargets(
254
260
  log.error("opencode is not installed. Run 'pal cli doctor' for details.");
255
261
  process.exit(1);
256
262
  }
263
+ if (requested.cursor && !h.cursor.available) {
264
+ log.error("Cursor is not installed. Run 'pal cli doctor' for details.");
265
+ process.exit(1);
266
+ }
257
267
  return requested;
258
268
  }
259
269
 
@@ -261,10 +271,12 @@ function resolveTargets(
261
271
  const targets = {
262
272
  claude: h.claude.available,
263
273
  opencode: h.opencode.available,
274
+ cursor: h.cursor.available,
264
275
  };
265
276
 
266
277
  if (!targets.claude) log.info("Skipping Claude Code (not installed)");
267
278
  if (!targets.opencode) log.info("Skipping opencode (not installed)");
279
+ if (!targets.cursor) log.info("Skipping Cursor (not installed)");
268
280
 
269
281
  return targets;
270
282
  }
@@ -312,6 +324,7 @@ interface DoctorResult {
312
324
  bun: ToolCheck;
313
325
  claude: ToolCheck;
314
326
  opencode: ToolCheck;
327
+ cursor: ToolCheck;
315
328
  hasAgent: boolean;
316
329
  }
317
330
 
@@ -322,6 +335,7 @@ function doctor(silent = false): DoctorResult {
322
335
  bun: { name: "bun", available: true, version: Bun.version },
323
336
  claude: { name: "claude", available: true },
324
337
  opencode: { name: "opencode", available: true },
338
+ cursor: { name: "cursor", available: true },
325
339
  hasAgent: true,
326
340
  };
327
341
  }
@@ -329,7 +343,8 @@ function doctor(silent = false): DoctorResult {
329
343
  const bun = { name: "bun", available: true, version: Bun.version };
330
344
  const claude = checkTool("claude");
331
345
  const opencode = checkTool("opencode");
332
- const hasAgent = claude.available || opencode.available;
346
+ const cursor = checkTool("cursor");
347
+ const hasAgent = claude.available || opencode.available || cursor.available;
333
348
 
334
349
  const home = palHome();
335
350
  const isRepo = existsSync(resolve(palPkg(), ".palroot"));
@@ -355,6 +370,9 @@ function doctor(silent = false): DoctorResult {
355
370
  opencode.available
356
371
  ? ok(`opencode ${opencode.version || ""}`.trim())
357
372
  : fail("opencode — not found");
373
+ cursor.available
374
+ ? ok(`Cursor ${cursor.version || ""}`.trim())
375
+ : fail("Cursor — not found");
358
376
  ok(`PAL home: ${home} (${isRepo ? "repo" : "package"} mode)`);
359
377
  telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
360
378
 
@@ -384,7 +402,7 @@ function doctor(silent = false): DoctorResult {
384
402
  console.log("");
385
403
  }
386
404
 
387
- return { bun, claude, opencode, hasAgent };
405
+ return { bun, claude, opencode, cursor, hasAgent };
388
406
  }
389
407
 
390
408
  // ── Commands ──
@@ -424,7 +442,7 @@ async function init(args: string[]) {
424
442
  }
425
443
  }
426
444
 
427
- async function install(targets: { claude: boolean; opencode: boolean }) {
445
+ async function install(targets: { claude: boolean; opencode: boolean; cursor: boolean }) {
428
446
  // Scaffold TELOS + PAL settings, then prompt for missing identity
429
447
  const { scaffoldTelos, scaffoldPalSettings } = await import("../targets/lib");
430
448
  const { promptIdentity } = await import("./setup-identity");
@@ -444,6 +462,12 @@ async function install(targets: { claude: boolean; opencode: boolean }) {
444
462
  console.log("");
445
463
  }
446
464
 
465
+ if (targets.cursor) {
466
+ console.log("━━━ Cursor ━━━");
467
+ await import("../targets/cursor/install");
468
+ console.log("");
469
+ }
470
+
447
471
  log.success("Done. Existing config was preserved — only new entries were added.");
448
472
  }
449
473
 
@@ -462,6 +486,12 @@ async function uninstall(args: string[]) {
462
486
  console.log("");
463
487
  }
464
488
 
489
+ if (targets.cursor) {
490
+ console.log("━━━ Cursor ━━━");
491
+ await import("../targets/cursor/uninstall");
492
+ console.log("");
493
+ }
494
+
465
495
  log.success(
466
496
  `PAL uninstalled. Your TELOS, skills, and memory are still in ${palHome()}.`
467
497
  );
@@ -641,6 +671,7 @@ async function status() {
641
671
 
642
672
  log.info(`Claude: ${platform.claudeDir()}`);
643
673
  log.info(`opencode: ${platform.opencodeDir()}`);
674
+ log.info(`Cursor: ${platform.cursorDir()}`);
644
675
  log.info(`Agents: ${platform.agentsDir()}`);
645
676
  console.log("");
646
677
 
@@ -6,7 +6,7 @@
6
6
  * learning digest, signal trends, failure patterns, active work state.
7
7
  */
8
8
 
9
- import { regenerateIfNeeded } from "./lib/claude-md";
9
+ import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
10
10
  import { buildSystemReminder } from "./lib/context";
11
11
  import { logDebug, logError } from "./lib/log";
12
12
 
@@ -28,11 +28,21 @@ try {
28
28
  logError("LoadContext:regenerate", err);
29
29
  }
30
30
 
31
- // --- Dynamic system-reminder to stdout (empty = nothing injected) ---
31
+ // --- Context to stdout ---
32
32
  try {
33
33
  const reminder = buildSystemReminder();
34
- if (reminder) console.log(reminder);
35
- logDebug("LoadContext", `Reminder injected: ${reminder ? reminder.length : 0} chars`);
34
+ if (!reminder) process.exit(0);
35
+
36
+ if (process.env.CURSOR_VERSION) {
37
+ // Cursor: no native user-level rules — inject AGENTS.md + dynamic context
38
+ const agentsMd = buildClaudeMd();
39
+ const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
40
+ process.stdout.write(JSON.stringify({ additional_context: context }));
41
+ } else {
42
+ // Claude Code: raw text
43
+ console.log(reminder);
44
+ }
45
+ logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
36
46
  } catch (err) {
37
47
  logError("LoadContext:reminder", err);
38
48
  }
@@ -5,6 +5,7 @@
5
5
  * Fail-open design: if anything goes wrong, the command is allowed through.
6
6
  */
7
7
 
8
+ import { blockResponse } from "./lib/agent";
8
9
  import { checkBashCommand, checkFilePath, WARN_COMMANDS } from "./lib/security";
9
10
  import { readStdinJSON } from "./lib/stdin";
10
11
 
@@ -22,27 +23,30 @@ try {
22
23
 
23
24
  const { tool_name, tool_input } = input;
24
25
 
25
- // Check Bash commands
26
- if (tool_name === "Bash" && tool_input.command) {
26
+ // Normalize tool names: Claude uses "Bash", Cursor uses "Shell"
27
+ const isBash = tool_name === "Bash" || tool_name === "Shell";
28
+ const isFileWrite = tool_name === "Write" || tool_name === "Edit";
29
+
30
+ // Check shell commands
31
+ if (isBash && tool_input.command) {
27
32
  const reason = checkBashCommand(tool_input.command);
28
33
  if (reason) {
29
- console.log(JSON.stringify({ decision: "block", reason: `Blocked: ${reason}` }));
34
+ process.stdout.write(blockResponse(`Blocked: ${reason}`));
30
35
  process.exit(0);
31
36
  }
32
37
 
33
38
  for (const pattern of WARN_COMMANDS) {
34
39
  if (pattern.test(tool_input.command)) {
35
- // Log but don't block — Claude Code's own permission system handles confirmation
36
40
  break;
37
41
  }
38
42
  }
39
43
  }
40
44
 
41
- // Check file path operations (Write, Edit)
42
- if ((tool_name === "Write" || tool_name === "Edit") && tool_input.file_path) {
45
+ // Check file path operations
46
+ if (isFileWrite && tool_input.file_path) {
43
47
  const fileReason = checkFilePath(tool_input.file_path);
44
48
  if (fileReason) {
45
- console.log(JSON.stringify({ decision: "block", reason: fileReason }));
49
+ process.stdout.write(blockResponse(fileReason));
46
50
  process.exit(0);
47
51
  }
48
52
  }
@@ -9,6 +9,7 @@
9
9
  * Fail-open: on any error, the skill is allowed through.
10
10
  */
11
11
 
12
+ import { blockResponse } from "./lib/agent";
12
13
  import { readStdinJSON } from "./lib/stdin";
13
14
 
14
15
  const BLOCKED_SKILLS = ["keybindings-help"];
@@ -27,13 +28,11 @@ try {
27
28
  const skill = (input.tool_input?.skill || "").toLowerCase().trim();
28
29
 
29
30
  if (BLOCKED_SKILLS.includes(skill)) {
30
- console.log(
31
- JSON.stringify({
32
- decision: "block",
33
- reason:
34
- 'BLOCKED: "keybindings-help" is a known false-positive triggered by position bias. ' +
35
- "The user did NOT ask about keybindings. Continue with their ACTUAL request.",
36
- })
31
+ process.stdout.write(
32
+ blockResponse(
33
+ 'BLOCKED: "keybindings-help" is a known false-positive triggered by position bias. ' +
34
+ "The user did NOT ask about keybindings. Continue with their ACTUAL request."
35
+ )
37
36
  );
38
37
  }
39
38
  } catch {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { checkReadmeSync } from "./handlers/readme-sync";
10
+ import { isCursor } from "./lib/agent";
10
11
  import { logError } from "./lib/log";
11
12
  import { readStdinJSON } from "./lib/stdin";
12
13
  import { runStopHandlers } from "./lib/stop";
@@ -22,7 +23,13 @@ interface StopHookInput {
22
23
  try {
23
24
  const decision = checkReadmeSync();
24
25
  if (decision.decision === "block") {
25
- console.log(JSON.stringify(decision));
26
+ if (isCursor()) {
27
+ // Cursor stop hook: followup_message auto-sends to the agent
28
+ process.stdout.write(JSON.stringify({ followup_message: decision.reason }));
29
+ } else {
30
+ // Claude Code: block decision
31
+ process.stdout.write(JSON.stringify(decision));
32
+ }
26
33
  process.exit(0);
27
34
  }
28
35
  } catch (err) {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Agent detection and output format adapters.
3
+ *
4
+ * Cursor and Claude Code use different JSON contracts for hook I/O.
5
+ * These helpers normalize the differences so hook handlers stay clean.
6
+ */
7
+
8
+ export type AgentType = "claude" | "cursor";
9
+
10
+ /** Detect which agent is running via environment variables */
11
+ export function detectAgent(): AgentType {
12
+ if (process.env.CURSOR_VERSION) return "cursor";
13
+ return "claude";
14
+ }
15
+
16
+ export const isCursor = () => detectAgent() === "cursor";
17
+
18
+ /**
19
+ * Format a "block this action" response for the current agent.
20
+ * Claude Code: { decision: "block", reason }
21
+ * Cursor: { permission: "deny", user_message }
22
+ */
23
+ export function blockResponse(reason: string): string {
24
+ if (isCursor()) {
25
+ return JSON.stringify({ permission: "deny", user_message: reason });
26
+ }
27
+ return JSON.stringify({ decision: "block", reason });
28
+ }
29
+
30
+ /**
31
+ * Format sessionStart context injection for the current agent.
32
+ * Claude Code: raw text to stdout
33
+ * Cursor: { additional_context: "..." }
34
+ */
35
+ export function sessionStartOutput(context: string): string {
36
+ if (isCursor()) {
37
+ return JSON.stringify({ additional_context: context });
38
+ }
39
+ return context;
40
+ }
@@ -67,6 +67,7 @@ const h = homedir();
67
67
  export const platform = {
68
68
  claudeDir: () => process.env.PAL_CLAUDE_DIR || resolve(h, ".claude"),
69
69
  opencodeDir: () => process.env.PAL_OPENCODE_DIR || resolve(h, ".config", "opencode"),
70
+ cursorDir: () => process.env.PAL_CURSOR_DIR || resolve(h, ".cursor"),
70
71
  agentsDir: () => process.env.PAL_AGENTS_DIR || resolve(h, ".agents"),
71
72
  } as const;
72
73
 
@@ -78,5 +79,6 @@ export const assets = {
78
79
  telosTemplates: () => pkg("assets", "templates", "telos"),
79
80
  agentsMdTemplate: () => pkg("assets", "templates", "AGENTS.md.template"),
80
81
  claudeSettingsTemplate: () => pkg("assets", "templates", "settings.claude.json"),
82
+ cursorHooksTemplate: () => pkg("assets", "templates", "hooks.cursor.json"),
81
83
  palDocs: () => pkg("assets", "templates", "PAL"),
82
84
  } as const;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * PAL — Cursor target installer (TypeScript)
3
+ * Merges hooks template into existing hooks.json (never overwrites).
4
+ * Symlinks skills. Generates AGENTS.md.
5
+ */
6
+
7
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
10
+ import { assets, palPkg, platform } from "../../hooks/lib/paths";
11
+ import {
12
+ copyPalDocs,
13
+ copySkills,
14
+ countSkills,
15
+ loadCursorHooksTemplate,
16
+ log,
17
+ mergeCursorHooks,
18
+ readJson,
19
+ scaffoldPalSettings,
20
+ writeJson,
21
+ } from "../lib";
22
+
23
+ const PKG_ROOT = palPkg().replaceAll("\\", "/");
24
+ const CURSOR_DIR = platform.cursorDir();
25
+ const HOOKS_FILE = resolve(CURSOR_DIR, "hooks.json");
26
+
27
+ // --- Ensure ~/.cursor/ exists ---
28
+ mkdirSync(CURSOR_DIR, { recursive: true });
29
+
30
+ // --- Merge hooks ---
31
+ if (existsSync(HOOKS_FILE)) {
32
+ const backup = `${HOOKS_FILE}.bak.${Date.now()}`;
33
+ copyFileSync(HOOKS_FILE, backup);
34
+ log.info("Backed up hooks.json");
35
+ }
36
+
37
+ const template = loadCursorHooksTemplate(assets.cursorHooksTemplate(), PKG_ROOT);
38
+ const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
39
+ const merged = mergeCursorHooks(existing, template);
40
+
41
+ writeJson(HOOKS_FILE, merged);
42
+ log.success("Merged PAL hooks into hooks.json");
43
+
44
+ // --- Symlink skills to ~/.cursor/skills/ ---
45
+ const cursorSkillsDir = resolve(CURSOR_DIR, "skills");
46
+ copySkills(cursorSkillsDir);
47
+
48
+ // --- Copy PAL system docs ---
49
+ const palDocsCount = copyPalDocs();
50
+ log.success(`Installed ${palDocsCount} PAL docs to ~/.agents/PAL/`);
51
+
52
+ // --- Scaffold PAL settings ---
53
+ scaffoldPalSettings();
54
+
55
+ // --- Generate AGENTS.md ---
56
+ regenerateIfNeeded();
57
+ log.success("Generated AGENTS.md");
58
+
59
+ log.success("Cursor installation complete");
60
+ console.log("");
61
+ log.info(`Skills: ${countSkills()}`);
62
+ log.info(`Hooks: ${HOOKS_FILE}`);
63
+ log.info(
64
+ "Note: Cursor tool matchers may need tuning — verify hook behavior after first use"
65
+ );
@@ -0,0 +1,52 @@
1
+ /**
2
+ * PAL — Cursor uninstaller (TypeScript)
3
+ * Removes only PAL-owned hooks from hooks.json. Preserves user hooks.
4
+ * Removes PAL skill symlinks.
5
+ */
6
+
7
+ import { copyFileSync, existsSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { assets, palPkg, platform } from "../../hooks/lib/paths";
10
+ import {
11
+ loadCursorHooksTemplate,
12
+ log,
13
+ readJson,
14
+ removePalDocs,
15
+ removeSkills,
16
+ unmergeCursorHooks,
17
+ writeJson,
18
+ } from "../lib";
19
+
20
+ const PKG_ROOT = palPkg().replaceAll("\\", "/");
21
+ const CURSOR_DIR = platform.cursorDir();
22
+ const HOOKS_FILE = resolve(CURSOR_DIR, "hooks.json");
23
+
24
+ // --- Remove PAL hooks from hooks.json ---
25
+ if (existsSync(HOOKS_FILE)) {
26
+ // Backup before modifying
27
+ copyFileSync(HOOKS_FILE, `${HOOKS_FILE}.bak.${Date.now()}`);
28
+ log.info("Backed up hooks.json");
29
+
30
+ const template = loadCursorHooksTemplate(assets.cursorHooksTemplate(), PKG_ROOT);
31
+ const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
32
+ const cleaned = unmergeCursorHooks(existing, template);
33
+
34
+ writeJson(HOOKS_FILE, cleaned);
35
+ log.success("Removed PAL hooks from hooks.json");
36
+ } else {
37
+ log.info("No hooks.json found, nothing to do");
38
+ }
39
+
40
+ // --- Remove PAL skill symlinks ---
41
+ const cursorSkillsDir = resolve(CURSOR_DIR, "skills");
42
+ const removed = removeSkills(cursorSkillsDir);
43
+ if (removed.length > 0) {
44
+ log.success(`Removed ${removed.length} skill(s): ${removed.join(", ")}`);
45
+ } else {
46
+ log.info("No PAL skills found");
47
+ }
48
+
49
+ // --- Remove PAL system docs ---
50
+ removePalDocs();
51
+
52
+ log.success("Cursor uninstall complete");
@@ -136,6 +136,85 @@ export function unmergeSettings(existing: Settings, template: Settings): Setting
136
136
  return result;
137
137
  }
138
138
 
139
+ // --- Cursor hooks.json merge/unmerge ---
140
+
141
+ type CursorHookEntry = {
142
+ type: string;
143
+ command: string;
144
+ matcher?: string;
145
+ timeout?: number;
146
+ };
147
+ type CursorHooks = {
148
+ version?: number;
149
+ hooks?: Record<string, CursorHookEntry[]>;
150
+ };
151
+
152
+ /**
153
+ * Load a Cursor hooks template, replacing {{PKG_ROOT}} with the actual path.
154
+ */
155
+ export function loadCursorHooksTemplate(
156
+ templatePath: string,
157
+ pkgRoot: string
158
+ ): CursorHooks {
159
+ const raw = readFileSync(templatePath, "utf-8");
160
+ const resolved = raw.replaceAll("{{PKG_ROOT}}", pkgRoot);
161
+ return JSON.parse(resolved) as CursorHooks;
162
+ }
163
+
164
+ /**
165
+ * Merge PAL hooks into an existing Cursor hooks.json.
166
+ * Deduplicates by command string within each event.
167
+ */
168
+ export function mergeCursorHooks(
169
+ existing: CursorHooks,
170
+ template: CursorHooks
171
+ ): CursorHooks {
172
+ const result: CursorHooks = { ...existing, version: existing.version ?? 1 };
173
+
174
+ if (template.hooks) {
175
+ if (!result.hooks) result.hooks = {};
176
+ for (const [event, entries] of Object.entries(template.hooks)) {
177
+ const current = result.hooks[event] ?? [];
178
+ for (const entry of entries) {
179
+ if (!current.some((e) => e.command === entry.command)) {
180
+ current.push(entry);
181
+ }
182
+ }
183
+ result.hooks[event] = current;
184
+ }
185
+ }
186
+
187
+ return result;
188
+ }
189
+
190
+ /**
191
+ * Remove PAL hooks from an existing Cursor hooks.json.
192
+ * Only removes entries whose command matches the template. Preserves user hooks.
193
+ */
194
+ export function unmergeCursorHooks(
195
+ existing: CursorHooks,
196
+ template: CursorHooks
197
+ ): CursorHooks {
198
+ const result: CursorHooks = { ...existing };
199
+
200
+ if (template.hooks && result.hooks) {
201
+ const palCommands = new Set<string>();
202
+ for (const entries of Object.values(template.hooks)) {
203
+ for (const entry of entries) {
204
+ palCommands.add(entry.command);
205
+ }
206
+ }
207
+
208
+ for (const [event, entries] of Object.entries(result.hooks)) {
209
+ result.hooks[event] = entries.filter((e) => !palCommands.has(e.command));
210
+ if (result.hooks[event].length === 0) delete result.hooks[event];
211
+ }
212
+ if (Object.keys(result.hooks).length === 0) delete result.hooks;
213
+ }
214
+
215
+ return result;
216
+ }
217
+
139
218
  // --- TELOS scaffolding ---
140
219
 
141
220
  /** Copy template files into telos/ without overwriting existing ones */