@cleocode/adapters 2026.4.101 → 2026.4.103

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.
Files changed (28) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1221 -16720
  4. package/dist/index.js.map +4 -4
  5. package/dist/providers/claude-code/install.d.ts +36 -0
  6. package/dist/providers/claude-code/install.d.ts.map +1 -1
  7. package/dist/providers/cursor/install.d.ts +36 -0
  8. package/dist/providers/cursor/install.d.ts.map +1 -1
  9. package/dist/providers/opencode/install.d.ts +47 -2
  10. package/dist/providers/opencode/install.d.ts.map +1 -1
  11. package/dist/providers/shared/hook-template-installer.d.ts +109 -0
  12. package/dist/providers/shared/hook-template-installer.d.ts.map +1 -0
  13. package/package.json +4 -4
  14. package/src/index.ts +11 -0
  15. package/src/providers/README.md +137 -0
  16. package/src/providers/claude-code/__tests__/hooks-install.test.ts +113 -0
  17. package/src/providers/claude-code/install.ts +129 -0
  18. package/src/providers/claude-code/templates/hooks/precompact-safestop.sh +52 -0
  19. package/src/providers/cursor/__tests__/hooks-install.test.ts +88 -0
  20. package/src/providers/cursor/install.ts +117 -0
  21. package/src/providers/cursor/templates/hooks/precompact.sh +47 -0
  22. package/src/providers/gemini-cli/templates/hooks/precompact.sh +47 -0
  23. package/src/providers/opencode/__tests__/hooks-install.test.ts +87 -0
  24. package/src/providers/opencode/install.ts +134 -3
  25. package/src/providers/opencode/templates/hooks/precompact.sh +42 -0
  26. package/src/providers/pi/templates/hooks/README.md +40 -0
  27. package/src/providers/shared/hook-template-installer.ts +268 -0
  28. 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
+ });