portable-agent-layer 0.15.1 → 0.17.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A cross-platform, cross-agent layer for portable AI workflows, memory, and accumulated knowledge.
4
4
 
5
- PAL lets you carry your agent context across **Windows**, **macOS**, and **Linux**, and work across different agent runtimes and interfaces such as **Claude** and **OpenCode**. Its core idea is simple: your knowledge and workflows should belong to **you**, not to a single machine, tool, or vendor.
5
+ PAL lets you carry your agent context across **Windows**, **macOS**, and **Linux**, and work across different agent runtimes and interfaces such as **Claude Code**, **opencode**, **Cursor**, and **Codex**. Its core idea is simple: your knowledge and workflows should belong to **you**, not to a single machine, tool, or vendor.
6
6
 
7
7
  > Inspired in part by [Daniel Miessler](https://danielmiessler.com)'s work on [Personal AI Infrastructure](https://github.com/danielmiessler/Personal_AI_Infrastructure). PAL is an independent open-source implementation focused on portability across platforms and agents. It is not affiliated with or endorsed by Daniel Miessler.
8
8
 
@@ -33,7 +33,7 @@ With PAL, you can:
33
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
34
 
35
35
  - [Bun](https://bun.sh) >= 1.3.0
36
- - At least one of: [Claude Code](https://claude.ai/code) or [opencode](https://opencode.ai)
36
+ - At least one of: [Claude Code](https://claude.ai/code), [opencode](https://opencode.ai), [Cursor](https://cursor.com), or [Codex](https://openai.com/index/introducing-codex/)
37
37
 
38
38
  ### Package mode (recommended)
39
39
 
@@ -90,9 +90,19 @@ 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
 
97
+ ### Supported agents
98
+
99
+ | Agent | Support | Skills | Hooks | AGENTS.md | Subagents |
100
+ |-------|---------|--------|-------|-----------|-----------|
101
+ | Claude Code | Full | Yes | Yes | Yes | Yes |
102
+ | opencode | Full | Yes | Yes (plugin) | Yes | Yes |
103
+ | Cursor | Full | Yes | Yes | Yes (injected via hook) | Yes |
104
+ | Codex | Partial | Yes | No | Yes | No |
105
+
96
106
  ---
97
107
 
98
108
  ## Environment variables
@@ -112,6 +122,8 @@ pal cli install # both (default)
112
122
  | `PAL_PKG` | Override package root |
113
123
  | `PAL_CLAUDE_DIR` | Override Claude config dir (default: `~/.claude`) |
114
124
  | `PAL_OPENCODE_DIR` | Override opencode config dir (default: `~/.config/opencode`) |
125
+ | `PAL_CURSOR_DIR` | Override Cursor config dir (default: `~/.cursor`) |
126
+ | `PAL_CODEX_DIR` | Override Codex config dir (default: `~/.codex`) |
115
127
  | `PAL_AGENTS_DIR` | Override agents dir (default: `~/.agents`) |
116
128
 
117
129
  ---
@@ -160,7 +172,7 @@ Your setup should be able to travel with you.
160
172
  ## Features
161
173
 
162
174
  - **Cross-platform**: works on Windows, macOS, and Linux
163
- - **Cross-agent**: designed to work across multiple agent ecosystems
175
+ - **Cross-agent**: full support for Claude Code, opencode, and Cursor; partial support for Codex
164
176
  - **Portable knowledge**: export and import accumulated knowledge
165
177
  - **TypeScript-first**: built in TypeScript from day one
166
178
  - **Open source**: hackable, inspectable, extensible
@@ -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
 
@@ -1,12 +1,12 @@
1
1
  # PAL — Portable Agent Layer
2
2
 
3
- PAL is a persistent, cross-platform, cross-agent layer for portable AI workflows, memory, and accumulated knowledge. It runs inside any compatible AI coding agent (Claude Code, opencode) as an interconnected set of skills, hooks, tools, memory, and configuration — all orchestrated by The Algorithm.
3
+ PAL is a persistent, cross-platform, cross-agent layer for portable AI workflows, memory, and accumulated knowledge. It runs inside any compatible AI coding agent (Claude Code, opencode, Cursor, Codex) as an interconnected set of skills, hooks, tools, memory, and configuration — all orchestrated by The Algorithm.
4
4
 
5
5
  ## How It Works
6
6
 
7
7
  **CLAUDE.md** (or the agent equivalent) is the entry point — generated from a template by the CLI installer. It defines execution modes, The Algorithm routing, and the context routing table. The agent loads it natively every session. A SessionStart hook keeps it fresh automatically.
8
8
 
9
- **The PAL home directory (`~/.agents/PAL/`)** contains all system documentation, user context (TELOS), and routing files. The rest of the system lives in the PAL package (`src/`) and the agent's config directory (`~/.claude/` or `~/.config/opencode/`).
9
+ **The PAL home directory (`~/.agents/PAL/`)** contains all system documentation, user context (TELOS), and routing files. The rest of the system lives in the PAL package (`src/`) and the agent's config directory (`~/.claude/`, `~/.config/opencode/`, `~/.cursor/`, or `~/.codex/`).
10
10
 
11
11
  ## Directory Structure
12
12
 
@@ -29,7 +29,7 @@ PAL is a persistent, cross-platform, cross-agent layer for portable AI workflows
29
29
  hooks/ # Session lifecycle hooks
30
30
  handlers/ # Individual stop/prompt handlers
31
31
  lib/ # Shared utilities
32
- targets/ # Agent-specific installers (Claude, opencode)
32
+ targets/ # Agent-specific installers (Claude, opencode, Cursor)
33
33
  tools/ # Standalone CLI tools
34
34
  assets/
35
35
  skills/ # Bundled skills (16+)
@@ -104,6 +104,7 @@ pal # Start agent session with auto-summary on exit
104
104
  pal cli init # Scaffold PAL home + install hooks
105
105
  pal cli install [--claude] # Register hooks/skills for Claude Code
106
106
  pal cli install [--opencode] # Register hooks/skills for opencode
107
+ pal cli install [--cursor] # Register hooks/skills for Cursor
107
108
  pal cli uninstall # Remove hooks/skills
108
109
  pal cli status # Show configuration
109
110
  pal cli doctor # Check prerequisites and health
@@ -116,8 +117,8 @@ pal cli update # Update PAL
116
117
 
117
118
  PAL is designed to work identically across:
118
119
  - **Platforms:** macOS, Linux, Windows
119
- - **Agents:** Claude Code, opencode (and future tools)
120
- - **Environment overrides:** `PAL_HOME`, `PAL_PKG`, `PAL_CLAUDE_DIR`, `PAL_OPENCODE_DIR`, `PAL_AGENTS_DIR`
120
+ - **Agents:** Claude Code (full), opencode (full), Cursor (full), Codex (partial — AGENTS.md and skills only, no hooks or subagents)
121
+ - **Environment overrides:** `PAL_HOME`, `PAL_PKG`, `PAL_CLAUDE_DIR`, `PAL_OPENCODE_DIR`, `PAL_CURSOR_DIR`, `PAL_CODEX_DIR`, `PAL_AGENTS_DIR`
121
122
 
122
123
  ## Extending PAL
123
124
 
@@ -433,9 +433,14 @@ src/targets/
433
433
  │ ├── install.ts # Register hooks + skills in opencode config
434
434
  │ ├── uninstall.ts
435
435
  │ └── plugin.ts # opencode plugin interface
436
+ ├── cursor/ # Cursor specific
437
+ │ ├── install.ts # Register hooks + skills in ~/.cursor/
438
+ │ └── uninstall.ts
436
439
  └── lib.ts # Shared: JSON read/write, settings merge, TELOS scaffold
437
440
  ```
438
441
 
442
+ Codex support is partial — AGENTS.md is symlinked to `~/.codex/AGENTS.md` automatically (no dedicated target installer needed).
443
+
439
444
  ### Path Resolution
440
445
 
441
446
  All paths resolve through `src/hooks/lib/paths.ts`:
@@ -446,6 +451,8 @@ All paths resolve through `src/hooks/lib/paths.ts`:
446
451
  | PAL package | Auto-detected from source | `PAL_PKG` |
447
452
  | Claude config | `~/.claude` | `PAL_CLAUDE_DIR` |
448
453
  | opencode config | `~/.config/opencode` | `PAL_OPENCODE_DIR` |
454
+ | Cursor config | `~/.cursor` | `PAL_CURSOR_DIR` |
455
+ | Codex config | `~/.codex` | `PAL_CODEX_DIR` |
449
456
  | Agents dir | `~/.agents` | `PAL_AGENTS_DIR` |
450
457
 
451
458
  ### Portability Contract
@@ -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.17.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
+ }
@@ -48,19 +48,25 @@ function latestMtime(...filePaths: string[]): number {
48
48
  return latest;
49
49
  }
50
50
 
51
- /** Ensure CLAUDE.md is a symlink pointing to AGENTS.md */
52
- function ensureSymlink(): void {
53
- const { outputPath, symlinkPath } = getOutputPaths();
51
+ /** Create or verify a symlink pointing to AGENTS.md */
52
+ function ensureOneSymlink(linkPath: string, targetPath: string): void {
54
53
  try {
55
- const stat = lstatSync(symlinkPath);
56
- // If it exists but isn't a symlink (e.g. old generated file), remove it
57
- if (!stat.isSymbolicLink()) unlinkSync(symlinkPath);
54
+ const stat = lstatSync(linkPath);
55
+ if (!stat.isSymbolicLink()) unlinkSync(linkPath);
58
56
  else return; // already a symlink, leave it
59
57
  } catch {
60
58
  // doesn't exist — create it
61
59
  }
62
- const relTarget = relative(dirname(symlinkPath), outputPath).replaceAll("\\", "/");
63
- symlinkSync(relTarget, symlinkPath);
60
+ ensureDir(dirname(linkPath));
61
+ const relTarget = relative(dirname(linkPath), targetPath).replaceAll("\\", "/");
62
+ symlinkSync(relTarget, linkPath);
63
+ }
64
+
65
+ /** Ensure all agent symlinks point to the canonical AGENTS.md */
66
+ function ensureSymlinks(): void {
67
+ const { outputPath, symlinkPath } = getOutputPaths();
68
+ ensureOneSymlink(symlinkPath, outputPath);
69
+ ensureOneSymlink(resolve(platform.codexDir(), "AGENTS.md"), outputPath);
64
70
  }
65
71
 
66
72
  /** Returns true if AGENTS.md needs to be regenerated */
@@ -150,7 +156,7 @@ export function buildClaudeMd(): string {
150
156
  /** Regenerate AGENTS.md if any source file is newer, and ensure CLAUDE.md symlink exists. Returns true if rebuilt. */
151
157
  export function regenerateIfNeeded(): boolean {
152
158
  const { outputPath } = getOutputPaths();
153
- ensureSymlink();
159
+ ensureSymlinks();
154
160
  if (!needsRebuild()) return false;
155
161
  ensureDir(dirname(outputPath));
156
162
  writeFileSync(outputPath, buildClaudeMd(), "utf-8");
@@ -67,6 +67,8 @@ 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"),
71
+ codexDir: () => process.env.PAL_CODEX_DIR || resolve(h, ".codex"),
70
72
  agentsDir: () => process.env.PAL_AGENTS_DIR || resolve(h, ".agents"),
71
73
  } as const;
72
74
 
@@ -78,5 +80,6 @@ export const assets = {
78
80
  telosTemplates: () => pkg("assets", "templates", "telos"),
79
81
  agentsMdTemplate: () => pkg("assets", "templates", "AGENTS.md.template"),
80
82
  claudeSettingsTemplate: () => pkg("assets", "templates", "settings.claude.json"),
83
+ cursorHooksTemplate: () => pkg("assets", "templates", "hooks.cursor.json"),
81
84
  palDocs: () => pkg("assets", "templates", "PAL"),
82
85
  } 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 */