@cleocode/adapters 2026.4.100 → 2026.4.102
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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1221 -16720
- package/dist/index.js.map +4 -4
- package/dist/providers/claude-code/install.d.ts +36 -0
- package/dist/providers/claude-code/install.d.ts.map +1 -1
- package/dist/providers/cursor/install.d.ts +36 -0
- package/dist/providers/cursor/install.d.ts.map +1 -1
- package/dist/providers/opencode/install.d.ts +47 -2
- package/dist/providers/opencode/install.d.ts.map +1 -1
- package/dist/providers/shared/hook-template-installer.d.ts +109 -0
- package/dist/providers/shared/hook-template-installer.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/index.ts +11 -0
- package/src/providers/README.md +137 -0
- package/src/providers/claude-code/__tests__/hooks-install.test.ts +113 -0
- package/src/providers/claude-code/install.ts +129 -0
- package/src/providers/claude-code/templates/hooks/precompact-safestop.sh +52 -0
- package/src/providers/cursor/__tests__/hooks-install.test.ts +88 -0
- package/src/providers/cursor/install.ts +117 -0
- package/src/providers/cursor/templates/hooks/precompact.sh +47 -0
- package/src/providers/gemini-cli/templates/hooks/precompact.sh +47 -0
- package/src/providers/opencode/__tests__/hooks-install.test.ts +87 -0
- package/src/providers/opencode/install.ts +134 -3
- package/src/providers/opencode/templates/hooks/precompact.sh +42 -0
- package/src/providers/pi/templates/hooks/README.md +40 -0
- package/src/providers/shared/hook-template-installer.ts +268 -0
- package/src/providers/shared/templates/hooks/cleo-precompact-core.sh +128 -0
|
@@ -22,6 +22,10 @@ import { homedir } from 'node:os';
|
|
|
22
22
|
import { dirname, join } from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
|
|
25
|
+
import {
|
|
26
|
+
type InstallHookTemplatesResult,
|
|
27
|
+
installProviderHookTemplates,
|
|
28
|
+
} from '../shared/hook-template-installer.js';
|
|
25
29
|
import { getCleoTemplatesTildePath } from '../shared/paths.js';
|
|
26
30
|
|
|
27
31
|
/**
|
|
@@ -86,6 +90,13 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
86
90
|
details.plugin = pluginResult;
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
// Step 4 (T1013): Install PreCompact hook templates + wire the handler
|
|
94
|
+
// command into ~/.claude/settings.json's `PreCompact` event.
|
|
95
|
+
const hookResult = this.installHookTemplates();
|
|
96
|
+
if (hookResult) {
|
|
97
|
+
details.hookTemplates = hookResult;
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
return {
|
|
90
101
|
success: true,
|
|
91
102
|
installedAt,
|
|
@@ -240,4 +251,122 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
240
251
|
|
|
241
252
|
return `Enabled ${pluginKey} in ~/.claude/settings.json`;
|
|
242
253
|
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Install the CLEO PreCompact hook templates for Claude Code (T1013).
|
|
257
|
+
*
|
|
258
|
+
* Writes two files to `~/.claude/hooks/`:
|
|
259
|
+
* 1. `cleo-precompact-core.sh` — universal CLEO safestop helper (shared
|
|
260
|
+
* across all providers; sourced by the provider-specific shim).
|
|
261
|
+
* 2. `precompact-safestop.sh` — Claude-Code-flavoured wrapper that invokes
|
|
262
|
+
* `cleo memory precompact-flush` and `cleo safestop`.
|
|
263
|
+
*
|
|
264
|
+
* Also registers a `PreCompact` entry in `~/.claude/settings.json` so Claude
|
|
265
|
+
* Code runs the hook when auto-compact fires (at 95% context).
|
|
266
|
+
*
|
|
267
|
+
* Idempotent: subsequent installs skip unchanged files and do not duplicate
|
|
268
|
+
* the settings.json hook entry.
|
|
269
|
+
*
|
|
270
|
+
* @returns Install summary (paths written + config change description), or
|
|
271
|
+
* `null` when no change was required.
|
|
272
|
+
*
|
|
273
|
+
* @task T1013
|
|
274
|
+
*/
|
|
275
|
+
private installHookTemplates(): {
|
|
276
|
+
templates: InstallHookTemplatesResult;
|
|
277
|
+
settingsEntryAdded: boolean;
|
|
278
|
+
} | null {
|
|
279
|
+
const home = homedir();
|
|
280
|
+
const hooksDir = join(home, '.claude', 'hooks');
|
|
281
|
+
|
|
282
|
+
// 1. Copy the bash templates next to each other so `source $SCRIPT_DIR/...` works.
|
|
283
|
+
// Template copy is best-effort so missing/locked filesystems (CI sandboxes,
|
|
284
|
+
// mocked `node:fs` in unit tests) don't fail the whole install.
|
|
285
|
+
let templates: InstallHookTemplatesResult;
|
|
286
|
+
try {
|
|
287
|
+
templates = installProviderHookTemplates({
|
|
288
|
+
provider: 'claude-code',
|
|
289
|
+
targetDir: hooksDir,
|
|
290
|
+
});
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 2. Wire the PreCompact event in ~/.claude/settings.json.
|
|
296
|
+
const settingsEntryAdded = this.registerPreCompactHook(
|
|
297
|
+
join(hooksDir, 'precompact-safestop.sh'),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
if (templates.installedFiles.length === 0 && !settingsEntryAdded) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { templates, settingsEntryAdded };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Register the PreCompact hook command in `~/.claude/settings.json`.
|
|
309
|
+
*
|
|
310
|
+
* The Claude Code native event name for the canonical `PreCompact` event is
|
|
311
|
+
* `PreCompact` (identity mapping — see `hook-mappings.json`). The entry is
|
|
312
|
+
* tagged with `# cleo-hook` so the uninstall flow can identify and remove
|
|
313
|
+
* our additions without touching user-authored hooks.
|
|
314
|
+
*
|
|
315
|
+
* @param shimPath - Absolute path to the installed `precompact-safestop.sh`.
|
|
316
|
+
* @returns `true` when a new hook entry was written, `false` when an
|
|
317
|
+
* equivalent entry was already present.
|
|
318
|
+
*
|
|
319
|
+
* @task T1013
|
|
320
|
+
*/
|
|
321
|
+
private registerPreCompactHook(shimPath: string): boolean {
|
|
322
|
+
const home = homedir();
|
|
323
|
+
const settingsPath = join(home, '.claude', 'settings.json');
|
|
324
|
+
|
|
325
|
+
let settings: Record<string, unknown> = {};
|
|
326
|
+
if (existsSync(settingsPath)) {
|
|
327
|
+
try {
|
|
328
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
329
|
+
} catch {
|
|
330
|
+
// Start fresh on corrupt settings — safer than aborting install.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const hooks = (settings.hooks as Record<string, unknown[]> | undefined) ?? {};
|
|
335
|
+
const preCompactEntries = (hooks.PreCompact as unknown[] | undefined) ?? [];
|
|
336
|
+
|
|
337
|
+
const alreadyWired = preCompactEntries.some(
|
|
338
|
+
(entry) =>
|
|
339
|
+
typeof entry === 'object' &&
|
|
340
|
+
entry !== null &&
|
|
341
|
+
Array.isArray((entry as Record<string, unknown>).hooks) &&
|
|
342
|
+
((entry as Record<string, unknown>).hooks as Array<Record<string, unknown>>).some(
|
|
343
|
+
(h) =>
|
|
344
|
+
typeof h.command === 'string' &&
|
|
345
|
+
(h.command as string).includes('# cleo-hook') &&
|
|
346
|
+
(h.command as string).includes('precompact-safestop.sh'),
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
if (alreadyWired) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
preCompactEntries.push({
|
|
355
|
+
matcher: '',
|
|
356
|
+
hooks: [
|
|
357
|
+
{
|
|
358
|
+
type: 'command',
|
|
359
|
+
command: `"${shimPath}" # cleo-hook`,
|
|
360
|
+
timeout: 30,
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
hooks.PreCompact = preCompactEntries;
|
|
366
|
+
settings.hooks = hooks;
|
|
367
|
+
|
|
368
|
+
mkdirSync(join(home, '.claude'), { recursive: true });
|
|
369
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
243
372
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# CLEO PreCompact Hook — Claude Code emergency safestop shim
|
|
3
|
+
#
|
|
4
|
+
# Triggers when Claude Code's auto-compact fires (at 95% context usage).
|
|
5
|
+
# Claude Code is a config-based hook provider (~/.claude/settings.json)
|
|
6
|
+
# whose canonical event `PreCompact` maps to the native event `PreCompact`.
|
|
7
|
+
#
|
|
8
|
+
# INSTALLATION (Claude Code):
|
|
9
|
+
# Copy to ~/.claude/hooks/ (alongside cleo-precompact-core.sh) or
|
|
10
|
+
# configure the installer to write this block to ~/.claude/settings.json:
|
|
11
|
+
#
|
|
12
|
+
# {
|
|
13
|
+
# "hooks": {
|
|
14
|
+
# "PreCompact": [{
|
|
15
|
+
# "type": "command",
|
|
16
|
+
# "command": "~/.claude/hooks/precompact-safestop.sh",
|
|
17
|
+
# "timeout": 30
|
|
18
|
+
# }]
|
|
19
|
+
# }
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# This shim is the Claude-Code-specific banner wrapper around the universal
|
|
23
|
+
# CLEO safestop sequence. The actual flush + safestop logic lives in the
|
|
24
|
+
# shared helper at cleo-precompact-core.sh (provider-neutral).
|
|
25
|
+
#
|
|
26
|
+
# Provides emergency fallback when an agent does not self-stop at the
|
|
27
|
+
# critical (90%) threshold. At 95% Claude Code triggers `PreCompact`, and
|
|
28
|
+
# this hook ensures CLEO session state is properly captured.
|
|
29
|
+
#
|
|
30
|
+
# @task T1013
|
|
31
|
+
# @provider claude-code
|
|
32
|
+
|
|
33
|
+
set -euo pipefail
|
|
34
|
+
|
|
35
|
+
# Source the shared CLEO core helper. Installed alongside this shim.
|
|
36
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
37
|
+
# shellcheck source=../../../shared/templates/hooks/cleo-precompact-core.sh
|
|
38
|
+
# shellcheck disable=SC1091
|
|
39
|
+
source "${SCRIPT_DIR}/cleo-precompact-core.sh"
|
|
40
|
+
|
|
41
|
+
# Run the universal flush + safestop sequence.
|
|
42
|
+
cleo_core_run_precompact "precompact-emergency"
|
|
43
|
+
|
|
44
|
+
# Claude-Code-specific status banner (only when a session was actually saved).
|
|
45
|
+
if [[ -n "${CLEO_PRECOMPACT_HANDOFF:-}" ]]; then
|
|
46
|
+
echo ""
|
|
47
|
+
echo "[CLEO] Emergency Safestop executed at PreCompact (Claude Code 95% context)."
|
|
48
|
+
echo " Session ended. Handoff saved to: ${CLEO_PRECOMPACT_HANDOFF}"
|
|
49
|
+
if [[ -n "${CLEO_PRECOMPACT_SESSION_ID:-}" ]]; then
|
|
50
|
+
echo " Resume with: cleo session resume ${CLEO_PRECOMPACT_SESSION_ID}"
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Cursor PreCompact hook-template installer (T1013).
|
|
3
|
+
*
|
|
4
|
+
* Validates that:
|
|
5
|
+
* - The Cursor adapter copies the shared helper + provider shim into
|
|
6
|
+
* `<projectDir>/.cursor/hooks/`.
|
|
7
|
+
* - `<projectDir>/.cursor/hooks.json` gains a `preCompact` entry (CAAMP's
|
|
8
|
+
* native event name for Cursor) tagged with the `# cleo-hook` sentinel.
|
|
9
|
+
* - Repeat invocations are idempotent.
|
|
10
|
+
*
|
|
11
|
+
* The test writes to a scoped tmp project directory so no user config is
|
|
12
|
+
* touched.
|
|
13
|
+
*
|
|
14
|
+
* @task T1013
|
|
15
|
+
* @epic T1000
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
22
|
+
import { CursorInstallProvider } from '../install.js';
|
|
23
|
+
|
|
24
|
+
describe('CursorInstallProvider — PreCompact hook templates', () => {
|
|
25
|
+
let projectDir: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
projectDir = mkdtempSync(join(tmpdir(), 'cleo-cursor-install-'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('installs both bash templates into <projectDir>/.cursor/hooks/', async () => {
|
|
36
|
+
const provider = new CursorInstallProvider();
|
|
37
|
+
|
|
38
|
+
const result = await provider.install({ projectDir });
|
|
39
|
+
expect(result.success).toBe(true);
|
|
40
|
+
|
|
41
|
+
const hookTemplates = (result.details?.hookTemplates ?? null) as {
|
|
42
|
+
templates: { installedFiles: string[]; targetDir: string };
|
|
43
|
+
hooksJsonEntryAdded: boolean;
|
|
44
|
+
} | null;
|
|
45
|
+
|
|
46
|
+
expect(hookTemplates).not.toBeNull();
|
|
47
|
+
expect(hookTemplates?.templates.targetDir).toBe(join(projectDir, '.cursor', 'hooks'));
|
|
48
|
+
const installed = hookTemplates?.templates.installedFiles ?? [];
|
|
49
|
+
expect(installed.some((p) => p.endsWith('cleo-precompact-core.sh'))).toBe(true);
|
|
50
|
+
expect(installed.some((p) => p.endsWith('precompact.sh'))).toBe(true);
|
|
51
|
+
|
|
52
|
+
// Provider shim sources the shared helper so the DRY contract holds.
|
|
53
|
+
const shim = readFileSync(join(projectDir, '.cursor', 'hooks', 'precompact.sh'), 'utf-8');
|
|
54
|
+
expect(shim).toContain('cleo-precompact-core.sh');
|
|
55
|
+
// Cursor's banner references its native event name for the canonical PreCompact.
|
|
56
|
+
expect(shim).toContain('preCompact');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('writes a preCompact entry into <projectDir>/.cursor/hooks.json tagged # cleo-hook', async () => {
|
|
60
|
+
const provider = new CursorInstallProvider();
|
|
61
|
+
await provider.install({ projectDir });
|
|
62
|
+
|
|
63
|
+
const hooksJsonPath = join(projectDir, '.cursor', 'hooks.json');
|
|
64
|
+
const config = JSON.parse(readFileSync(hooksJsonPath, 'utf-8')) as {
|
|
65
|
+
hooks?: Record<string, Array<{ command?: string; type?: string }>>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const entries = config.hooks?.preCompact ?? [];
|
|
69
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
70
|
+
const entry = entries[0];
|
|
71
|
+
expect(entry).toBeDefined();
|
|
72
|
+
expect(entry?.type).toBe('command');
|
|
73
|
+
expect(entry?.command).toContain('precompact.sh');
|
|
74
|
+
expect(entry?.command).toContain('# cleo-hook');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is idempotent — re-running install does not duplicate the preCompact entry', async () => {
|
|
78
|
+
const provider = new CursorInstallProvider();
|
|
79
|
+
await provider.install({ projectDir });
|
|
80
|
+
await provider.install({ projectDir });
|
|
81
|
+
|
|
82
|
+
const hooksJsonPath = join(projectDir, '.cursor', 'hooks.json');
|
|
83
|
+
const config = JSON.parse(readFileSync(hooksJsonPath, 'utf-8')) as {
|
|
84
|
+
hooks?: { preCompact?: unknown[] };
|
|
85
|
+
};
|
|
86
|
+
expect((config.hooks?.preCompact ?? []).length).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Handles CLEO installation into Cursor environments:
|
|
5
5
|
* - Ensures .cursorrules has CLEO @-references (legacy format)
|
|
6
6
|
* - Creates .cursor/rules/cleo.mdc with CLEO references (modern format)
|
|
7
|
+
* - Installs PreCompact hook shell shims + wires them into .cursor/hooks.json (T1013)
|
|
7
8
|
*
|
|
8
9
|
* Cursor supports two instruction file formats:
|
|
9
10
|
* 1. Legacy: .cursorrules (flat file, project root)
|
|
@@ -12,11 +13,16 @@
|
|
|
12
13
|
* This provider writes to both for maximum compatibility.
|
|
13
14
|
*
|
|
14
15
|
* @task T5240
|
|
16
|
+
* @task T1013
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
20
|
import { join } from 'node:path';
|
|
19
21
|
import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
|
|
22
|
+
import {
|
|
23
|
+
type InstallHookTemplatesResult,
|
|
24
|
+
installProviderHookTemplates,
|
|
25
|
+
} from '../shared/hook-template-installer.js';
|
|
20
26
|
import { getCleoTemplatesTildePath } from '../shared/paths.js';
|
|
21
27
|
|
|
22
28
|
/**
|
|
@@ -35,6 +41,7 @@ const INSTRUCTION_REFERENCES = [
|
|
|
35
41
|
* Manages CLEO's integration with Cursor by:
|
|
36
42
|
* 1. Creating/updating .cursorrules with @-references (legacy)
|
|
37
43
|
* 2. Creating .cursor/rules/cleo.mdc with @-references (modern)
|
|
44
|
+
* 3. Installing the PreCompact hook shim + registering it in .cursor/hooks.json (T1013)
|
|
38
45
|
*
|
|
39
46
|
* @remarks
|
|
40
47
|
* Installation is idempotent and writes to both instruction file formats
|
|
@@ -61,6 +68,13 @@ export class CursorInstallProvider implements AdapterInstallProvider {
|
|
|
61
68
|
details.instructionFiles = this.getUpdatedFileList(projectDir);
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
// Step 2 (T1013): Install PreCompact hook templates + wire the handler
|
|
72
|
+
// command into .cursor/hooks.json's `preCompact` event.
|
|
73
|
+
const hookResult = this.installHookTemplates(projectDir);
|
|
74
|
+
if (hookResult) {
|
|
75
|
+
details.hookTemplates = hookResult;
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
return {
|
|
65
79
|
success: true,
|
|
66
80
|
installedAt,
|
|
@@ -207,4 +221,107 @@ export class CursorInstallProvider implements AdapterInstallProvider {
|
|
|
207
221
|
files.push(join(projectDir, '.cursor', 'rules', 'cleo.mdc'));
|
|
208
222
|
return files;
|
|
209
223
|
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Install the CLEO PreCompact hook templates for Cursor (T1013).
|
|
227
|
+
*
|
|
228
|
+
* Writes two files to `<projectDir>/.cursor/hooks/`:
|
|
229
|
+
* 1. `cleo-precompact-core.sh` — universal CLEO safestop helper.
|
|
230
|
+
* 2. `precompact.sh` — Cursor-flavoured wrapper.
|
|
231
|
+
*
|
|
232
|
+
* Also registers a `preCompact` entry in `.cursor/hooks.json`. The native
|
|
233
|
+
* event name `preCompact` comes from CAAMP's `hook-mappings.json` SSoT.
|
|
234
|
+
*
|
|
235
|
+
* Idempotent: re-running install skips unchanged files and avoids
|
|
236
|
+
* duplicating the hooks.json entry.
|
|
237
|
+
*
|
|
238
|
+
* @param projectDir - Project root directory.
|
|
239
|
+
* @returns Install summary, or `null` when no change was required.
|
|
240
|
+
*
|
|
241
|
+
* @task T1013
|
|
242
|
+
*/
|
|
243
|
+
private installHookTemplates(projectDir: string): {
|
|
244
|
+
templates: InstallHookTemplatesResult;
|
|
245
|
+
hooksJsonEntryAdded: boolean;
|
|
246
|
+
} | null {
|
|
247
|
+
const hooksDir = join(projectDir, '.cursor', 'hooks');
|
|
248
|
+
|
|
249
|
+
// Template copy is best-effort so missing/locked filesystems (CI sandboxes,
|
|
250
|
+
// mocked `node:fs` in unit tests) don't fail the whole install.
|
|
251
|
+
let templates: InstallHookTemplatesResult;
|
|
252
|
+
try {
|
|
253
|
+
templates = installProviderHookTemplates({
|
|
254
|
+
provider: 'cursor',
|
|
255
|
+
targetDir: hooksDir,
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const hooksJsonEntryAdded = this.registerPreCompactHook(
|
|
262
|
+
projectDir,
|
|
263
|
+
join(hooksDir, 'precompact.sh'),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
if (templates.installedFiles.length === 0 && !hooksJsonEntryAdded) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { templates, hooksJsonEntryAdded };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Register the PreCompact hook command in `.cursor/hooks.json`.
|
|
275
|
+
*
|
|
276
|
+
* Cursor's native event name for the canonical `PreCompact` is `preCompact`
|
|
277
|
+
* (camelCase — see CAAMP `hook-mappings.json`). Entries are tagged with a
|
|
278
|
+
* `# cleo-hook` comment so they can be cleanly removed on uninstall.
|
|
279
|
+
*
|
|
280
|
+
* @param projectDir - Project root directory.
|
|
281
|
+
* @param shimPath - Absolute path to the installed `precompact.sh`.
|
|
282
|
+
* @returns `true` when a new entry was written, `false` when already wired.
|
|
283
|
+
*
|
|
284
|
+
* @task T1013
|
|
285
|
+
*/
|
|
286
|
+
private registerPreCompactHook(projectDir: string, shimPath: string): boolean {
|
|
287
|
+
const hooksJsonPath = join(projectDir, '.cursor', 'hooks.json');
|
|
288
|
+
|
|
289
|
+
let config: Record<string, unknown> = {};
|
|
290
|
+
if (existsSync(hooksJsonPath)) {
|
|
291
|
+
try {
|
|
292
|
+
config = JSON.parse(readFileSync(hooksJsonPath, 'utf-8'));
|
|
293
|
+
} catch {
|
|
294
|
+
// Start fresh on corrupt config.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const hooks = (config.hooks as Record<string, unknown[]> | undefined) ?? {};
|
|
299
|
+
const entries = (hooks.preCompact as unknown[] | undefined) ?? [];
|
|
300
|
+
|
|
301
|
+
const alreadyWired = entries.some(
|
|
302
|
+
(entry) =>
|
|
303
|
+
typeof entry === 'object' &&
|
|
304
|
+
entry !== null &&
|
|
305
|
+
typeof (entry as Record<string, unknown>).command === 'string' &&
|
|
306
|
+
((entry as Record<string, unknown>).command as string).includes('# cleo-hook') &&
|
|
307
|
+
((entry as Record<string, unknown>).command as string).includes('precompact.sh'),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if (alreadyWired) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
entries.push({
|
|
315
|
+
type: 'command',
|
|
316
|
+
command: `"${shimPath}" # cleo-hook`,
|
|
317
|
+
timeout: 30,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
hooks.preCompact = entries;
|
|
321
|
+
config.hooks = hooks;
|
|
322
|
+
|
|
323
|
+
mkdirSync(join(projectDir, '.cursor'), { recursive: true });
|
|
324
|
+
writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
210
327
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# CLEO PreCompact Hook — Cursor emergency safestop shim
|
|
3
|
+
#
|
|
4
|
+
# Cursor is a config-based hook provider (.cursor/hooks.json) whose canonical
|
|
5
|
+
# event `PreCompact` maps to the native event `preCompact`. Handler types
|
|
6
|
+
# supported: `command`, `prompt`. This file targets `command` handlers.
|
|
7
|
+
#
|
|
8
|
+
# INSTALLATION (Cursor):
|
|
9
|
+
# Copy to .cursor/hooks/precompact.sh (alongside cleo-precompact-core.sh)
|
|
10
|
+
# and add the following to .cursor/hooks.json:
|
|
11
|
+
#
|
|
12
|
+
# {
|
|
13
|
+
# "hooks": {
|
|
14
|
+
# "preCompact": [{
|
|
15
|
+
# "type": "command",
|
|
16
|
+
# "command": "./.cursor/hooks/precompact.sh",
|
|
17
|
+
# "timeout": 30
|
|
18
|
+
# }]
|
|
19
|
+
# }
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# The universal flush + safestop sequence is implemented in the shared helper
|
|
23
|
+
# cleo-precompact-core.sh. This shim only adds a Cursor-flavoured banner.
|
|
24
|
+
#
|
|
25
|
+
# @task T1013
|
|
26
|
+
# @provider cursor
|
|
27
|
+
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
|
|
30
|
+
# Source the shared CLEO core helper. Installed alongside this shim.
|
|
31
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
+
# shellcheck source=../../../shared/templates/hooks/cleo-precompact-core.sh
|
|
33
|
+
# shellcheck disable=SC1091
|
|
34
|
+
source "${SCRIPT_DIR}/cleo-precompact-core.sh"
|
|
35
|
+
|
|
36
|
+
# Run the universal flush + safestop sequence.
|
|
37
|
+
cleo_core_run_precompact "precompact-emergency"
|
|
38
|
+
|
|
39
|
+
# Cursor-specific status banner (only when a session was actually saved).
|
|
40
|
+
if [[ -n "${CLEO_PRECOMPACT_HANDOFF:-}" ]]; then
|
|
41
|
+
echo ""
|
|
42
|
+
echo "[CLEO] Emergency Safestop executed at Cursor preCompact event."
|
|
43
|
+
echo " Session ended. Handoff saved to: ${CLEO_PRECOMPACT_HANDOFF}"
|
|
44
|
+
if [[ -n "${CLEO_PRECOMPACT_SESSION_ID:-}" ]]; then
|
|
45
|
+
echo " Resume with: cleo session resume ${CLEO_PRECOMPACT_SESSION_ID}"
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# CLEO PreCompact Hook — Gemini CLI emergency safestop shim
|
|
3
|
+
#
|
|
4
|
+
# Gemini CLI is a config-based hook provider (~/.gemini/settings.json) whose
|
|
5
|
+
# canonical event `PreCompact` maps to the native event `PreCompress`.
|
|
6
|
+
# Handler type supported: `command`.
|
|
7
|
+
#
|
|
8
|
+
# INSTALLATION (Gemini CLI):
|
|
9
|
+
# Copy to ~/.gemini/hooks/precompact.sh (alongside cleo-precompact-core.sh)
|
|
10
|
+
# and add the following to ~/.gemini/settings.json:
|
|
11
|
+
#
|
|
12
|
+
# {
|
|
13
|
+
# "hooks": {
|
|
14
|
+
# "PreCompress": [{
|
|
15
|
+
# "type": "command",
|
|
16
|
+
# "command": "~/.gemini/hooks/precompact.sh",
|
|
17
|
+
# "timeout": 30
|
|
18
|
+
# }]
|
|
19
|
+
# }
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# The universal flush + safestop sequence lives in cleo-precompact-core.sh.
|
|
23
|
+
# This shim only adds a Gemini-flavoured banner.
|
|
24
|
+
#
|
|
25
|
+
# @task T1013
|
|
26
|
+
# @provider gemini-cli
|
|
27
|
+
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
|
|
30
|
+
# Source the shared CLEO core helper. Installed alongside this shim.
|
|
31
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
+
# shellcheck source=../../../shared/templates/hooks/cleo-precompact-core.sh
|
|
33
|
+
# shellcheck disable=SC1091
|
|
34
|
+
source "${SCRIPT_DIR}/cleo-precompact-core.sh"
|
|
35
|
+
|
|
36
|
+
# Run the universal flush + safestop sequence.
|
|
37
|
+
cleo_core_run_precompact "precompact-emergency"
|
|
38
|
+
|
|
39
|
+
# Gemini-CLI-specific status banner (only when a session was actually saved).
|
|
40
|
+
if [[ -n "${CLEO_PRECOMPACT_HANDOFF:-}" ]]; then
|
|
41
|
+
echo ""
|
|
42
|
+
echo "[CLEO] Emergency Safestop executed at Gemini CLI PreCompress event."
|
|
43
|
+
echo " Session ended. Handoff saved to: ${CLEO_PRECOMPACT_HANDOFF}"
|
|
44
|
+
if [[ -n "${CLEO_PRECOMPACT_SESSION_ID:-}" ]]; then
|
|
45
|
+
echo " Resume with: cleo session resume ${CLEO_PRECOMPACT_SESSION_ID}"
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the OpenCode PreCompact hook-template installer (T1013).
|
|
3
|
+
*
|
|
4
|
+
* OpenCode does not have config-based hooks — it uses a JavaScript plugin
|
|
5
|
+
* system. The installer therefore produces:
|
|
6
|
+
*
|
|
7
|
+
* 1. `<projectDir>/.opencode/plugins/hooks/cleo-precompact-core.sh` (shared helper)
|
|
8
|
+
* 2. `<projectDir>/.opencode/plugins/hooks/precompact.sh` (OpenCode shim)
|
|
9
|
+
* 3. `<projectDir>/.opencode/plugins/cleo-precompact.js` (JS plugin that
|
|
10
|
+
* spawns the shim on `experimental.session.compacting`).
|
|
11
|
+
*
|
|
12
|
+
* @task T1013
|
|
13
|
+
* @epic T1000
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
20
|
+
import { OpenCodeInstallProvider } from '../install.js';
|
|
21
|
+
|
|
22
|
+
describe('OpenCodeInstallProvider — PreCompact hook templates', () => {
|
|
23
|
+
let projectDir: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
projectDir = mkdtempSync(join(tmpdir(), 'cleo-opencode-install-'));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('installs bash templates under <projectDir>/.opencode/plugins/hooks/', async () => {
|
|
34
|
+
const provider = new OpenCodeInstallProvider();
|
|
35
|
+
const result = await provider.install({ projectDir });
|
|
36
|
+
expect(result.success).toBe(true);
|
|
37
|
+
|
|
38
|
+
const hookTemplates = (result.details?.hookTemplates ?? null) as {
|
|
39
|
+
templates: { installedFiles: string[]; targetDir: string };
|
|
40
|
+
pluginWritten: boolean;
|
|
41
|
+
} | null;
|
|
42
|
+
|
|
43
|
+
expect(hookTemplates).not.toBeNull();
|
|
44
|
+
expect(hookTemplates?.templates.targetDir).toBe(
|
|
45
|
+
join(projectDir, '.opencode', 'plugins', 'hooks'),
|
|
46
|
+
);
|
|
47
|
+
const installed = hookTemplates?.templates.installedFiles ?? [];
|
|
48
|
+
expect(installed.some((p) => p.endsWith('cleo-precompact-core.sh'))).toBe(true);
|
|
49
|
+
expect(installed.some((p) => p.endsWith('precompact.sh'))).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('generates a JS plugin wrapping the experimental.session.compacting event', async () => {
|
|
53
|
+
const provider = new OpenCodeInstallProvider();
|
|
54
|
+
await provider.install({ projectDir });
|
|
55
|
+
|
|
56
|
+
const pluginPath = join(projectDir, '.opencode', 'plugins', 'cleo-precompact.js');
|
|
57
|
+
const plugin = readFileSync(pluginPath, 'utf-8');
|
|
58
|
+
|
|
59
|
+
// Plugin subscribes to OpenCode's native PreCompact event
|
|
60
|
+
expect(plugin).toContain('experimental.session.compacting');
|
|
61
|
+
// Plugin spawns the bash shim (not core internals) — universal CLI surface.
|
|
62
|
+
expect(plugin).toContain('spawn');
|
|
63
|
+
expect(plugin).toContain('precompact.sh');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is idempotent — re-running install does not rewrite unchanged plugin files', async () => {
|
|
67
|
+
const provider = new OpenCodeInstallProvider();
|
|
68
|
+
const first = await provider.install({ projectDir });
|
|
69
|
+
const second = await provider.install({ projectDir });
|
|
70
|
+
|
|
71
|
+
const firstResult = (first.details?.hookTemplates ?? null) as {
|
|
72
|
+
pluginWritten: boolean;
|
|
73
|
+
} | null;
|
|
74
|
+
// The first install writes both the bash templates and the plugin.
|
|
75
|
+
expect(firstResult?.pluginWritten).toBe(true);
|
|
76
|
+
|
|
77
|
+
// On the second pass the generated plugin file matches the source exactly
|
|
78
|
+
// and both bash templates are byte-identical, so the install helper
|
|
79
|
+
// returns `null` (no change required).
|
|
80
|
+
expect(second.details?.hookTemplates ?? null).toBeNull();
|
|
81
|
+
|
|
82
|
+
// And the plugin file contents have not been clobbered between the two runs.
|
|
83
|
+
const pluginPath = join(projectDir, '.opencode', 'plugins', 'cleo-precompact.js');
|
|
84
|
+
const plugin = readFileSync(pluginPath, 'utf-8');
|
|
85
|
+
expect(plugin).toContain('experimental.session.compacting');
|
|
86
|
+
});
|
|
87
|
+
});
|