feat(#777): register Cursor-native hooks (.cursor/hooks.json) for session-start/post-tool parity (#831)
* feat(#777): register Cursor-native hooks (.cursor/hooks.json) for session-start/post-tool parity - Add gsd-cursor-session-start.js: injects STATE.md presence reminder (or new-project nudge) into Cursor sessions via the sessionStart hook event - Add gsd-cursor-post-tool.js: emits an additional_context nudge when write-class tool calls touch .planning/ files (postToolUse hook event) - Add 'cursor-hooks-json' installSurface to runtime-config-adapter-registry; writeCursorHooksJson/reconcileCursorHooksJson write the canonical { version: 1, hooks: { sessionStart, postToolUse } } JSON shape with idempotent reconciliation that preserves user-owned hook entries - Hook scripts are copied with /gsd:→gsd- rewrite so installed files contain no colon-form slash-command refs (bug-376 invariant) - 20 new tests in tests/cursor-hooks.test.cjs cover all reconciler paths, entry helpers, removal, runtime adapter surface, and hook script behavior - Update CONTEXT.md, ARCHITECTURE.md, installer-migrations.md, and 000-first-time-baseline.cts to include Cursor hooks.json surface Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#777): build hooks/dist on demand in bug-376 test for scoped/windows CI hooks/dist is gitignored and only produced by `npm run build:hooks`. The CI scoped (ubuntu-latest/node-22) and windows (windows-latest/node-24) test jobs do NOT run build:hooks before executing tests, so bug-376's prerequisite suite was failing with "hooks/dist not found" on both legs. Add ensureHooksDist() helper (mirrors bug-3357 pattern) that builds hooks/dist on demand in the before() hooks of prerequisite and Suite 3. Also add ensureHooksDist() call to Suite 3's before() so the snapshot step is also hermetic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,20 @@ const { cleanup } = require('./helpers.cjs');
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
|
||||
|
||||
/**
|
||||
* Ensure hooks/dist is populated before any suite that reads it.
|
||||
* hooks/dist/ is gitignored and only produced by `npm run build:hooks`.
|
||||
* In CI the scoped/windows test jobs do NOT run build:hooks before running
|
||||
* tests, so the first test that needs hooks/dist would fail. This mirrors
|
||||
* the pattern used in bug-3357-codex-legacy-hooks-json-migration.test.cjs.
|
||||
*/
|
||||
function ensureHooksDist() {
|
||||
if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -92,6 +106,12 @@ function colonRefs(content) {
|
||||
// Prerequisite: hooks/dist must exist (built by `npm run build:hooks`)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — prerequisite: hooks/dist is present', () => {
|
||||
before(() => {
|
||||
// hooks/dist is gitignored; build it on demand so this test is
|
||||
// deterministic in CI scoped/windows jobs that don't pre-run build:hooks.
|
||||
ensureHooksDist();
|
||||
});
|
||||
|
||||
test('hooks/dist directory exists (run npm run build:hooks if missing)', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(HOOKS_DIST_DIR),
|
||||
@@ -189,11 +209,13 @@ describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing)
|
||||
//
|
||||
// Note: Cursor does NOT install hooks/dist files (Cursor skips the hooks
|
||||
// install step entirely — see install.js gate around line 8829). The Cursor
|
||||
// /gsd: rewrite applies in `copyWithPathReplacement` to JS files under the
|
||||
// agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's
|
||||
// installed .js files under .cursor/ have no /gsd: colon refs.
|
||||
// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and
|
||||
// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777).
|
||||
// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh
|
||||
// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files
|
||||
// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's
|
||||
// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/
|
||||
// directory contains only the Cursor-specific managed hooks.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => {
|
||||
let tmpDir;
|
||||
@@ -242,15 +264,32 @@ describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (r
|
||||
);
|
||||
});
|
||||
|
||||
test('2c: Cursor install does not install a hooks/ directory (hooks are cursor-skipped)', () => {
|
||||
// Cursor intentionally skips the hooks/dist copy step — verify this contract
|
||||
// is still honored so we know the regression only concerns hook .js files
|
||||
// for runtimes that DO install hooks (claude, qwen, hermes etc.).
|
||||
test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => {
|
||||
// Since issue #777, Cursor installs gsd-cursor-session-start.js and
|
||||
// gsd-cursor-post-tool.js into <configDir>/hooks/. These are Cursor-native
|
||||
// hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.).
|
||||
// Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks.
|
||||
const hooksDir = path.join(tmpDir, '.cursor', 'hooks');
|
||||
assert.strictEqual(
|
||||
assert.ok(
|
||||
fs.existsSync(hooksDir),
|
||||
false,
|
||||
'Cursor install must NOT create a hooks/ directory — Cursor skips the hooks install step',
|
||||
'Cursor install must create a hooks/ directory for its managed hook scripts (#777)',
|
||||
);
|
||||
const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js'];
|
||||
for (const hook of CLAUDE_BUNDLED_HOOKS) {
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(hooksDir, hook)),
|
||||
false,
|
||||
`Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`,
|
||||
);
|
||||
}
|
||||
// The two Cursor-specific managed hooks must be present.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')),
|
||||
'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')),
|
||||
'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -262,6 +301,9 @@ describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', (
|
||||
let snapshotBefore;
|
||||
|
||||
before(() => {
|
||||
// Ensure hooks/dist is built before snapshotting; it may be absent in CI
|
||||
// scoped/windows jobs that don't pre-run build:hooks (#777 fix).
|
||||
ensureHooksDist();
|
||||
// Snapshot hooks/dist JS files before any install in this suite
|
||||
snapshotBefore = {};
|
||||
if (fs.existsSync(HOOKS_DIST_DIR)) {
|
||||
|
||||
462
tests/cursor-hooks.test.cjs
Normal file
462
tests/cursor-hooks.test.cjs
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Tests for Cursor hooks.json lifecycle hook registration (issue #777).
|
||||
*
|
||||
* Cursor v2.4+ supports a hooks.json system with events including sessionStart
|
||||
* and postToolUse. GSD registers two managed command hooks so Cursor users get
|
||||
* baseline parity with Claude Code and Gemini.
|
||||
*
|
||||
* Test plan:
|
||||
* T1 reconcileCursorHooksJson — creates new hooks.json with both events
|
||||
* T2 reconcileCursorHooksJson — idempotent (re-run writes same content)
|
||||
* T3 reconcileCursorHooksJson — preserves user-owned entries in sessionStart
|
||||
* T4 reconcileCursorHooksJson — preserves user-owned entries in postToolUse
|
||||
* T5 reconcileCursorHooksJson — remove-only (managedEntries=null) strips managed, keeps user
|
||||
* T6 reconcileCursorHooksJson — handles nested { version, hooks: {...} } shape
|
||||
* T7 reconcileCursorHooksJson — handles flat (no version) shape
|
||||
* T8 reconcileCursorHooksJson — corrupted JSON throws descriptive error
|
||||
* T9 isManagedCursorHookEntry — returns true for GSD-marked entries
|
||||
* T10 isManagedCursorHookEntry — returns false for user entries
|
||||
* T11 buildCursorHookEntry — emits correct shape with marker
|
||||
* T12 removeCursorHooksJson — removes hooks.json when it becomes empty
|
||||
* T13 removeCursorHooksJson — preserves file when user entries remain
|
||||
* T14 runtime-config-adapter — cursor now has 'cursor-hooks-json' surface
|
||||
* T15 INSTALL_SURFACES — 'cursor-hooks-json' in the valid surfaces list
|
||||
* T16 Hook scripts exist in hooks/
|
||||
* T17 Hook script content — sessionStart script emits JSON with additional_context
|
||||
* T18 Hook script content — postToolUse script emits JSON {} for non-write tools
|
||||
* T19 GSD_CURSOR_HOOK_MARKER constant is exported
|
||||
* T20 GSD_CURSOR_SESSION_HOOK_SCRIPT / GSD_CURSOR_POST_TOOL_HOOK_SCRIPT constants
|
||||
*/
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Hook script text IS what Cursor loads. Testing script content tests the deployed contract.
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
reconcileCursorHooksJson,
|
||||
isManagedCursorHookEntry,
|
||||
buildCursorHookEntry,
|
||||
removeCursorHooksJson,
|
||||
GSD_CURSOR_HOOK_MARKER,
|
||||
GSD_CURSOR_SESSION_HOOK_SCRIPT,
|
||||
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const {
|
||||
resolveRuntimeConfigIntent,
|
||||
INSTALL_SURFACES,
|
||||
} = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeHooksJson(obj) {
|
||||
return JSON.stringify(obj, null, 2) + '\n';
|
||||
}
|
||||
|
||||
function readHooksJson(dir) {
|
||||
const p = path.join(dir, 'hooks.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
}
|
||||
|
||||
function managedEntry(command) {
|
||||
return { type: 'command', command, [GSD_CURSOR_HOOK_MARKER]: true };
|
||||
}
|
||||
|
||||
function userEntry(command) {
|
||||
return { type: 'command', command };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T1: Creates new hooks.json with both events
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('reconcileCursorHooksJson', () => {
|
||||
test('T1: creates new hooks.json with sessionStart and postToolUse', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
const result = reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /path/gsd-cursor-session-start.js'),
|
||||
postToolUse: managedEntry('/node /path/gsd-cursor-post-tool.js'),
|
||||
});
|
||||
|
||||
assert.equal(result.wrote, true);
|
||||
assert.equal(result.changed, true);
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
assert.ok(parsed, 'hooks.json must exist');
|
||||
|
||||
// Cursor requires the canonical nested { "version": 1, "hooks": { ... } } shape.
|
||||
assert.equal(parsed.version, 1, 'hooks.json must have version: 1');
|
||||
assert.ok(parsed.hooks && typeof parsed.hooks === 'object', 'hooks.json must have top-level hooks object');
|
||||
const hookTable = parsed.hooks;
|
||||
assert.ok(Array.isArray(hookTable.sessionStart), 'sessionStart must be an array');
|
||||
assert.ok(Array.isArray(hookTable.postToolUse), 'postToolUse must be an array');
|
||||
assert.equal(hookTable.sessionStart.length, 1);
|
||||
assert.equal(hookTable.postToolUse.length, 1);
|
||||
assert.equal(hookTable.sessionStart[0][GSD_CURSOR_HOOK_MARKER], true);
|
||||
assert.equal(hookTable.postToolUse[0][GSD_CURSOR_HOOK_MARKER], true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T2: Idempotent
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T2: idempotent — second run produces same content (no write)', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
const entries = {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'),
|
||||
};
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, entries);
|
||||
const result2 = reconcileCursorHooksJson(hooksJsonPath, entries);
|
||||
|
||||
assert.equal(result2.wrote, false, 'second run must not write (idempotent)');
|
||||
assert.equal(result2.changed, false, 'content must not change on second run');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T3: Preserves user-owned entries in sessionStart
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T3: preserves user-owned entries in sessionStart', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
// Pre-populate with user-owned entry.
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [userEntry('/my-team/hook.sh')],
|
||||
},
|
||||
}));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'),
|
||||
});
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed;
|
||||
assert.ok(Array.isArray(hookTable.sessionStart));
|
||||
// Both user and GSD entries must survive.
|
||||
assert.equal(hookTable.sessionStart.length, 2, 'user + GSD entry must coexist');
|
||||
const userStays = hookTable.sessionStart.some((e) => e.command === '/my-team/hook.sh');
|
||||
const gsdAdded = hookTable.sessionStart.some((e) => e[GSD_CURSOR_HOOK_MARKER]);
|
||||
assert.ok(userStays, 'user-owned entry must be preserved');
|
||||
assert.ok(gsdAdded, 'GSD managed entry must be present');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T4: Preserves user-owned entries in postToolUse
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T4: preserves user-owned entries in postToolUse', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
hooks: {
|
||||
postToolUse: [userEntry('/user/post-tool.sh')],
|
||||
},
|
||||
}));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'),
|
||||
});
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed;
|
||||
assert.ok(Array.isArray(hookTable.postToolUse));
|
||||
assert.equal(hookTable.postToolUse.length, 2);
|
||||
assert.ok(hookTable.postToolUse.some((e) => e.command === '/user/post-tool.sh'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T5: Remove-only (managedEntries=null) strips managed, keeps user entries
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T5: remove-only (null managedEntries) strips GSD entries, preserves user', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [
|
||||
userEntry('/user/session.sh'),
|
||||
managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
],
|
||||
postToolUse: [
|
||||
managedEntry('/node /gsd-cursor-post-tool.js'),
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, null);
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed;
|
||||
// sessionStart user entry survives; postToolUse key should be absent.
|
||||
assert.ok(Array.isArray(hookTable.sessionStart), 'sessionStart must remain (user entry)');
|
||||
assert.equal(hookTable.sessionStart.length, 1, 'only user entry remains');
|
||||
assert.equal(hookTable.sessionStart[0].command, '/user/session.sh');
|
||||
assert.equal(hookTable.postToolUse, undefined, 'postToolUse key must be removed (was GSD-only)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T6: Handles nested { version, hooks: {...} } shape
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T6: handles nested { version, hooks } shape', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({ version: 1, hooks: {} }));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
});
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
assert.ok(parsed.hooks, 'top-level hooks key must be preserved');
|
||||
assert.equal(parsed.version, 1, 'version must be preserved');
|
||||
assert.ok(Array.isArray(parsed.hooks.sessionStart));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T7: Handles flat (no version) shape
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T7: handles flat shape (no wrapper object)', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({ sessionStart: [] }));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
});
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
// Flat input is migrated to nested canonical shape: { version: 1, hooks: { ... } }.
|
||||
assert.equal(parsed.version, 1, 'migrated flat shape must get version: 1');
|
||||
assert.ok(parsed.hooks && typeof parsed.hooks === 'object', 'migrated shape must have hooks object');
|
||||
assert.ok(Array.isArray(parsed.hooks.sessionStart), 'sessionStart must be in hooks object after migration');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T8: Corrupted JSON throws descriptive error
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T8: throws descriptive error for corrupted hooks.json', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, '{ not valid json }');
|
||||
|
||||
assert.throws(
|
||||
() => reconcileCursorHooksJson(hooksJsonPath, { sessionStart: managedEntry('/cmd') }),
|
||||
(err) => {
|
||||
assert.match(err.message, /Cursor hooks\.json parse failed/i);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T9-T11: Entry helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isManagedCursorHookEntry / buildCursorHookEntry', () => {
|
||||
test('T9: isManagedCursorHookEntry returns true for GSD-marked entry', () => {
|
||||
const entry = { type: 'command', command: '/x', [GSD_CURSOR_HOOK_MARKER]: true };
|
||||
assert.equal(isManagedCursorHookEntry(entry), true);
|
||||
});
|
||||
|
||||
test('T10: isManagedCursorHookEntry returns false for user-owned entry', () => {
|
||||
const entry = { type: 'command', command: '/user/hook.sh' };
|
||||
assert.equal(isManagedCursorHookEntry(entry), false);
|
||||
assert.equal(isManagedCursorHookEntry(null), false);
|
||||
assert.equal(isManagedCursorHookEntry({}), false);
|
||||
});
|
||||
|
||||
test('T11: buildCursorHookEntry emits correct shape', () => {
|
||||
const entry = buildCursorHookEntry('/usr/local/bin/node /path/to/hook.js');
|
||||
assert.equal(entry.type, 'command');
|
||||
assert.equal(entry[GSD_CURSOR_HOOK_MARKER], true);
|
||||
assert.ok(typeof entry.command === 'string');
|
||||
// Forward slashes only.
|
||||
assert.ok(!entry.command.includes('\\'), 'command must use forward slashes');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T12-T13: removeCursorHooksJson
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('removeCursorHooksJson', () => {
|
||||
test('T12: removes hooks.json when it becomes empty after GSD removal', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
// GSD-only file.
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [managedEntry('/node /gsd-cursor-session-start.js')],
|
||||
postToolUse: [managedEntry('/node /gsd-cursor-post-tool.js')],
|
||||
},
|
||||
}));
|
||||
|
||||
const result = removeCursorHooksJson(dir);
|
||||
assert.equal(result.changed, true);
|
||||
// File should be removed (was GSD-only).
|
||||
assert.equal(fs.existsSync(hooksJsonPath), false, 'empty hooks.json must be removed');
|
||||
});
|
||||
|
||||
test('T13: preserves hooks.json when user entries remain', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [
|
||||
managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
userEntry('/user/session.sh'),
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
removeCursorHooksJson(dir);
|
||||
|
||||
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must remain (user entries present)');
|
||||
const parsed = readHooksJson(dir);
|
||||
const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed;
|
||||
assert.equal(hookTable.sessionStart.length, 1);
|
||||
assert.equal(hookTable.sessionStart[0].command, '/user/session.sh');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T14: runtime-config-adapter — cursor now has 'cursor-hooks-json' surface
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T14: cursor runtime has installSurface cursor-hooks-json', () => {
|
||||
const intent = resolveRuntimeConfigIntent('cursor');
|
||||
assert.equal(intent.installSurface, 'cursor-hooks-json');
|
||||
assert.equal(intent.writesSharedSettings, false);
|
||||
assert.equal(intent.finishPermissionWriter, null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T15: INSTALL_SURFACES includes 'cursor-hooks-json'
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T15: INSTALL_SURFACES includes cursor-hooks-json', () => {
|
||||
assert.ok(
|
||||
INSTALL_SURFACES.includes('cursor-hooks-json'),
|
||||
"'cursor-hooks-json' must be in INSTALL_SURFACES"
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T16: Hook script files exist in hooks/
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T16: Cursor hook script files exist in hooks/', () => {
|
||||
const hooksDir = path.join(__dirname, '..', 'hooks');
|
||||
const sessionStart = path.join(hooksDir, GSD_CURSOR_SESSION_HOOK_SCRIPT);
|
||||
const postTool = path.join(hooksDir, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT);
|
||||
assert.ok(fs.existsSync(sessionStart), `${GSD_CURSOR_SESSION_HOOK_SCRIPT} must exist in hooks/`);
|
||||
assert.ok(fs.existsSync(postTool), `${GSD_CURSOR_POST_TOOL_HOOK_SCRIPT} must exist in hooks/`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T17: sessionStart script emits JSON with additional_context on stdin close
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T17: gsd-cursor-session-start.js emits JSON with additional_context', (t, done) => {
|
||||
const hooksDir = path.join(__dirname, '..', 'hooks');
|
||||
const scriptPath = path.join(hooksDir, GSD_CURSOR_SESSION_HOOK_SCRIPT);
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const input = JSON.stringify({ session_id: 'test-123', composer_mode: 'agent' });
|
||||
const child = execFile(process.execPath, [scriptPath], {
|
||||
timeout: 10000,
|
||||
cwd: os.tmpdir(), // no .planning/ dir here — should get MSG_ABSENT
|
||||
}, (err, stdout) => {
|
||||
if (err && !stdout) { done(err); return; }
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(stdout); } catch (e) { done(new Error(`stdout not valid JSON: ${stdout}`)); return; }
|
||||
assert.ok('additional_context' in parsed, 'output must have additional_context field');
|
||||
assert.ok(typeof parsed.additional_context === 'string', 'additional_context must be a string');
|
||||
assert.ok(parsed.additional_context.length > 0, 'additional_context must not be empty');
|
||||
done();
|
||||
});
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T18: postToolUse script emits {} for non-write tools
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T18: gsd-cursor-post-tool.js emits {} for non-write tool names', (t, done) => {
|
||||
const hooksDir = path.join(__dirname, '..', 'hooks');
|
||||
const scriptPath = path.join(hooksDir, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT);
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const input = JSON.stringify({
|
||||
tool_name: 'Read',
|
||||
tool_input: { path: '/some/file.js' },
|
||||
tool_output: 'contents',
|
||||
duration: 42,
|
||||
});
|
||||
|
||||
const child = execFile(process.execPath, [scriptPath], {
|
||||
timeout: 10000,
|
||||
cwd: os.tmpdir(),
|
||||
}, (err, stdout) => {
|
||||
if (err && !stdout) { done(err); return; }
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(stdout); } catch (e) { done(new Error(`stdout not valid JSON: ${stdout}`)); return; }
|
||||
// Non-write tool → empty response (no additional_context).
|
||||
assert.ok(typeof parsed === 'object', 'output must be an object');
|
||||
// additional_context should be absent for non-write, non-planning tool.
|
||||
assert.equal(parsed.additional_context, undefined, 'no additional_context for non-write tool');
|
||||
done();
|
||||
});
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T19: GSD_CURSOR_HOOK_MARKER is exported and is a non-empty string
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T19: GSD_CURSOR_HOOK_MARKER is exported and is a non-empty string', () => {
|
||||
assert.equal(typeof GSD_CURSOR_HOOK_MARKER, 'string');
|
||||
assert.ok(GSD_CURSOR_HOOK_MARKER.length > 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T20: Script name constants are exported and correct
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T20: GSD_CURSOR_SESSION_HOOK_SCRIPT and GSD_CURSOR_POST_TOOL_HOOK_SCRIPT are exported', () => {
|
||||
assert.equal(GSD_CURSOR_SESSION_HOOK_SCRIPT, 'gsd-cursor-session-start.js');
|
||||
assert.equal(GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, 'gsd-cursor-post-tool.js');
|
||||
});
|
||||
@@ -31,7 +31,7 @@ const EXPECTED_TABLE = [
|
||||
{ runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'cursor', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'cursor', installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
];
|
||||
@@ -160,8 +160,8 @@ describe('installSurface correctness', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('cline').installSurface, 'cline-rules');
|
||||
});
|
||||
|
||||
test('cursor -> "profile-marker-only"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'profile-marker-only');
|
||||
test('cursor -> "cursor-hooks-json"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'cursor-hooks-json');
|
||||
});
|
||||
|
||||
test('windsurf -> "profile-marker-only"', () => {
|
||||
@@ -236,10 +236,11 @@ describe('INSTALL_SURFACES export', () => {
|
||||
'codex-toml',
|
||||
'copilot-instructions',
|
||||
'cline-rules',
|
||||
'cursor-hooks-json',
|
||||
'profile-marker-only',
|
||||
]);
|
||||
|
||||
test('INSTALL_SURFACES contains exactly the 5 surface strings', () => {
|
||||
test('INSTALL_SURFACES contains exactly the 6 surface strings', () => {
|
||||
assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user