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 +16 -4
- package/assets/templates/AGENTS.md.template +3 -3
- package/assets/templates/PAL/README.md +6 -5
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +7 -0
- package/assets/templates/hooks.cursor.json +33 -0
- package/package.json +1 -1
- package/src/cli/index.ts +43 -12
- package/src/hooks/LoadContext.ts +14 -4
- package/src/hooks/SecurityValidator.ts +11 -7
- package/src/hooks/SkillGuard.ts +6 -7
- package/src/hooks/StopOrchestrator.ts +8 -1
- package/src/hooks/lib/agent.ts +40 -0
- package/src/hooks/lib/claude-md.ts +15 -9
- package/src/hooks/lib/paths.ts +3 -0
- package/src/targets/cursor/install.ts +65 -0
- package/src/targets/cursor/uninstall.ts +52 -0
- package/src/targets/lib.ts +79 -0
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 **
|
|
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)
|
|
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
|
|
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**:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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
|
|
233
|
-
|
|
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
|
|
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
|
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -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
|
-
// ---
|
|
31
|
+
// --- Context to stdout ---
|
|
32
32
|
try {
|
|
33
33
|
const reminder = buildSystemReminder();
|
|
34
|
-
if (reminder)
|
|
35
|
-
|
|
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
|
-
//
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
if (
|
|
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
|
-
|
|
49
|
+
process.stdout.write(blockResponse(fileReason));
|
|
46
50
|
process.exit(0);
|
|
47
51
|
}
|
|
48
52
|
}
|
package/src/hooks/SkillGuard.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
52
|
-
function
|
|
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(
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
159
|
+
ensureSymlinks();
|
|
154
160
|
if (!needsRebuild()) return false;
|
|
155
161
|
ensureDir(dirname(outputPath));
|
|
156
162
|
writeFileSync(outputPath, buildClaudeMd(), "utf-8");
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -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");
|
package/src/targets/lib.ts
CHANGED
|
@@ -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 */
|