@workit-dev/cursor 0.0.1 → 1.0.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/.cursor-plugin/plugin.json +38 -0
- package/README.md +56 -43
- package/assets/logo.svg +4 -0
- package/assets/templates/workit-contract.md +13 -0
- package/commands/wk-architecture.md +5 -0
- package/commands/wk-bdd.md +5 -0
- package/commands/wk-continue.md +5 -0
- package/commands/wk-debug.md +5 -0
- package/commands/wk-deslop.md +5 -0
- package/commands/wk-fanout.md +5 -0
- package/commands/wk-implement.md +5 -0
- package/commands/wk-retro.md +5 -0
- package/commands/wk-review.md +5 -0
- package/commands/wk-shape.md +5 -0
- package/commands/wk-ship.md +5 -0
- package/commands/wk-test-audit.md +5 -0
- package/commands/wk-verify-app.md +5 -0
- package/dist/cursor-session-start.js +27182 -0
- package/dist/mcp-server.js +30655 -0
- package/dist/workit-hook.js +24510 -0
- package/hooks/hooks-cursor.json +48 -0
- package/hooks/launch-runtime.mjs +276 -0
- package/hooks/launch.mjs +137 -0
- package/hooks/node-resolve.mjs +249 -0
- package/hooks/workit-paths.mjs +242 -0
- package/mcp.json +8 -0
- package/package.json +54 -6
- package/rules/workit-contract.mdc +38 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"sessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/launch.mjs\" workit-cursor-session-start"
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"preToolUse": [
|
|
10
|
+
{
|
|
11
|
+
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/launch.mjs\" workit-cursor-hook",
|
|
12
|
+
"matcher": "Write|Edit|Delete|Shell|Remove|ApplyPatch|Apply_Patch|Patch|Rename|Mkdir|Mv|Cp|Touch",
|
|
13
|
+
"failClosed": false
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"beforeShellExecution": [
|
|
17
|
+
{
|
|
18
|
+
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/launch.mjs\" workit-cursor-hook",
|
|
19
|
+
"failClosed": false
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"subagentStart": [
|
|
23
|
+
{
|
|
24
|
+
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/launch.mjs\" workit-cursor-hook",
|
|
25
|
+
"failClosed": false
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"subagentStop": [
|
|
29
|
+
{
|
|
30
|
+
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/launch.mjs\" workit-cursor-hook",
|
|
31
|
+
"failClosed": false
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"stop": [
|
|
35
|
+
{
|
|
36
|
+
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/launch.mjs\" workit-cursor-hook",
|
|
37
|
+
"loop_limit": 1,
|
|
38
|
+
"failClosed": false
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"preCompact": [
|
|
42
|
+
{
|
|
43
|
+
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/launch.mjs\" workit-cursor-hook",
|
|
44
|
+
"failClosed": false
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Cursor hook launch resolution and fail-open runner, shared by the launcher
|
|
2
|
+
// (launch.mjs, including its `--probe` mode for `workit doctor`). Side-effect
|
|
3
|
+
// free: importing it runs nothing.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { stateDir } from "./workit-paths.mjs";
|
|
15
|
+
|
|
16
|
+
const CURSOR_HOOK_PACKAGE = "@workit-dev/cursor";
|
|
17
|
+
|
|
18
|
+
/** npm bin name -> bundled entry under dist/. */
|
|
19
|
+
const CURSOR_HOOK_BINS = {
|
|
20
|
+
"workit-cursor-hook": "workit-hook.js",
|
|
21
|
+
"workit-cursor-session-start": "cursor-session-start.js",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LOCAL_TIMEOUT_MS = 20_000;
|
|
25
|
+
const NPX_TIMEOUT_MS = 30_000;
|
|
26
|
+
// A deny can carry a long agent message; spawnSync's 1 MiB default would turn
|
|
27
|
+
// an oversized answer into a launcher failure (and fail open).
|
|
28
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
/** @param {string} root */
|
|
31
|
+
const pluginVersion = (root) => {
|
|
32
|
+
try {
|
|
33
|
+
const version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
|
|
34
|
+
return typeof version === "string" && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
|
|
35
|
+
? version
|
|
36
|
+
: null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The workit-cursor version a global bin belongs to (its package.json), if any.
|
|
43
|
+
* @param {string} bin */
|
|
44
|
+
const globalBinVersion = (bin) => {
|
|
45
|
+
try {
|
|
46
|
+
for (let dir = path.dirname(realpathSync(bin)); ; dir = path.dirname(dir)) {
|
|
47
|
+
const pkg = path.join(dir, "package.json");
|
|
48
|
+
if (existsSync(pkg)) {
|
|
49
|
+
const parsed = JSON.parse(readFileSync(pkg, "utf8"));
|
|
50
|
+
return parsed.name === CURSOR_HOOK_PACKAGE ? pluginVersion(dir) : null;
|
|
51
|
+
}
|
|
52
|
+
if (path.dirname(dir) === dir) return null;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** @param {string} name @param {NodeJS.ProcessEnv} env */
|
|
60
|
+
const onPath = (name, env) => {
|
|
61
|
+
const names = process.platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
|
|
62
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
63
|
+
// Relative entries (`.`, `node_modules/.bin`) resolve against the
|
|
64
|
+
// workspace Cursor runs the hook in: never trust them.
|
|
65
|
+
if (!dir || !path.isAbsolute(dir)) continue;
|
|
66
|
+
for (const candidate of names.map((n) => path.join(dir, n))) {
|
|
67
|
+
try {
|
|
68
|
+
const st = statSync(candidate);
|
|
69
|
+
if (st.isFile() && (process.platform === "win32" || (st.mode & 0o111) !== 0))
|
|
70
|
+
return candidate;
|
|
71
|
+
} catch {
|
|
72
|
+
/* keep scanning */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {{ mode: "local" | "npx-pinned", source: string, version: string | null,
|
|
81
|
+
* command: string, args: string[], verbatim?: boolean, timeoutMs: number }} LaunchCandidate
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
// cmd.exe metacharacters, escaped with `^` (ported from workit-core's
|
|
85
|
+
// checks.ts, which this plain-JS launcher cannot import).
|
|
86
|
+
const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
|
|
87
|
+
|
|
88
|
+
/** One argument for a `cmd /d /s /c "…"` line; `double` for node_modules/.bin
|
|
89
|
+
* shims, which re-parse their arguments.
|
|
90
|
+
* @param {string} arg @param {boolean} double */
|
|
91
|
+
const cmdArgument = (arg, double) => {
|
|
92
|
+
let out = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
|
|
93
|
+
out = `"${out}"`.replace(CMD_META, "^$1");
|
|
94
|
+
return double ? out.replace(CMD_META, "^$1") : out;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How to spawn `file args…`. Node cannot spawn a Windows `.cmd`/`.bat` shim
|
|
99
|
+
* directly, so it runs as `cmd.exe /d /s /c "<line>"` with every metacharacter
|
|
100
|
+
* escaped and the line passed verbatim: a path such as `C:\A&B\x.cmd` must
|
|
101
|
+
* never split into a second command.
|
|
102
|
+
* @param {string} file @param {string[]} args @param {NodeJS.ProcessEnv} env
|
|
103
|
+
* @returns {{ command: string, args: string[], verbatim: boolean }}
|
|
104
|
+
*/
|
|
105
|
+
export const cursorSpawnPlan = (file, args, env) => {
|
|
106
|
+
if (!/\.(?:cmd|bat)$/i.test(file)) return { command: file, args, verbatim: false };
|
|
107
|
+
const double = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(file);
|
|
108
|
+
const line = [
|
|
109
|
+
path.win32.normalize(file).replace(CMD_META, "^$1"),
|
|
110
|
+
...args.map((arg) => cmdArgument(arg, double)),
|
|
111
|
+
].join(" ");
|
|
112
|
+
return {
|
|
113
|
+
command: env.ComSpec ?? env.COMSPEC ?? "cmd.exe",
|
|
114
|
+
args: ["/d", "/s", "/c", `"${line}"`],
|
|
115
|
+
verbatim: true,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ordered launch candidates for `bin`; an empty list means "missing".
|
|
121
|
+
* @param {{ root: string, bin: string, env: NodeJS.ProcessEnv, node?: string }} options
|
|
122
|
+
* @returns {LaunchCandidate[]}
|
|
123
|
+
*/
|
|
124
|
+
export const resolveCursorHookLaunch = ({ root, bin, env, node = process.execPath }) => {
|
|
125
|
+
/** @type {LaunchCandidate[]} */
|
|
126
|
+
const candidates = [];
|
|
127
|
+
const entry = Object.hasOwn(CURSOR_HOOK_BINS, bin)
|
|
128
|
+
? CURSOR_HOOK_BINS[/** @type {keyof typeof CURSOR_HOOK_BINS} */ (bin)]
|
|
129
|
+
: null;
|
|
130
|
+
if (!entry) return candidates;
|
|
131
|
+
const version = pluginVersion(root);
|
|
132
|
+
const bundled = path.join(root, "dist", entry);
|
|
133
|
+
if (existsSync(bundled))
|
|
134
|
+
candidates.push({
|
|
135
|
+
mode: "local",
|
|
136
|
+
source: bundled,
|
|
137
|
+
version,
|
|
138
|
+
command: node,
|
|
139
|
+
args: [bundled],
|
|
140
|
+
timeoutMs: LOCAL_TIMEOUT_MS,
|
|
141
|
+
});
|
|
142
|
+
const global = onPath(bin, env);
|
|
143
|
+
if (global)
|
|
144
|
+
candidates.push({
|
|
145
|
+
mode: "local",
|
|
146
|
+
source: global,
|
|
147
|
+
version: globalBinVersion(global),
|
|
148
|
+
...cursorSpawnPlan(global, [], env),
|
|
149
|
+
timeoutMs: LOCAL_TIMEOUT_MS,
|
|
150
|
+
});
|
|
151
|
+
const npx = onPath("npx", env);
|
|
152
|
+
if (version && npx)
|
|
153
|
+
candidates.push({
|
|
154
|
+
mode: "npx-pinned",
|
|
155
|
+
source: `${CURSOR_HOOK_PACKAGE}@${version}`,
|
|
156
|
+
version,
|
|
157
|
+
// WORKIT_CURSOR_HOOK_NPX_OFFLINE=1 (the doctor's probe) never downloads.
|
|
158
|
+
...cursorSpawnPlan(
|
|
159
|
+
npx,
|
|
160
|
+
[
|
|
161
|
+
"-y",
|
|
162
|
+
env.WORKIT_CURSOR_HOOK_NPX_OFFLINE === "1" ? "--offline" : "--prefer-offline",
|
|
163
|
+
`--package=${CURSOR_HOOK_PACKAGE}@${version}`,
|
|
164
|
+
bin,
|
|
165
|
+
],
|
|
166
|
+
env,
|
|
167
|
+
),
|
|
168
|
+
timeoutMs: NPX_TIMEOUT_MS,
|
|
169
|
+
});
|
|
170
|
+
return candidates;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run the first launchable candidate with `payload` on stdin.
|
|
175
|
+
* @param {{ candidates: LaunchCandidate[], payload: string, env: NodeJS.ProcessEnv,
|
|
176
|
+
* spawn?: typeof spawnSync }} options
|
|
177
|
+
* @returns {{ stdout: string, exitCode: number, warning: string | null }}
|
|
178
|
+
*/
|
|
179
|
+
export const runCursorHookLaunch = ({ candidates, payload, env, spawn = spawnSync }) => {
|
|
180
|
+
const failOpen = (/** @type {string} */ reason) => ({
|
|
181
|
+
stdout: "{}\n",
|
|
182
|
+
exitCode: 0,
|
|
183
|
+
warning: `[workit] Cursor hook unavailable: ${reason}`,
|
|
184
|
+
});
|
|
185
|
+
if (candidates.length === 0)
|
|
186
|
+
return failOpen("no local workit-cursor runtime and no npx to fetch the pinned one");
|
|
187
|
+
const timeoutMs = Number(env.WORKIT_CURSOR_HOOK_TIMEOUT_MS) || null;
|
|
188
|
+
const errors = [];
|
|
189
|
+
for (const candidate of candidates) {
|
|
190
|
+
const run = spawn(candidate.command, candidate.args, {
|
|
191
|
+
input: payload,
|
|
192
|
+
encoding: "utf8",
|
|
193
|
+
env,
|
|
194
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
195
|
+
timeout: timeoutMs ?? candidate.timeoutMs,
|
|
196
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
197
|
+
windowsHide: true,
|
|
198
|
+
windowsVerbatimArguments: candidate.verbatim === true,
|
|
199
|
+
});
|
|
200
|
+
const code = /** @type {{ code?: string } | undefined} */ (run.error)?.code;
|
|
201
|
+
// The process never started: try the next candidate on the same payload.
|
|
202
|
+
if (run.error && (code === "ENOENT" || code === "EACCES")) {
|
|
203
|
+
errors.push(`${candidate.source}: ${run.error.message}`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// EPIPE only means the hook exited without reading all of stdin; its exit
|
|
207
|
+
// status still stands.
|
|
208
|
+
if (run.error && !(code === "EPIPE" && typeof run.status === "number"))
|
|
209
|
+
return failOpen(`${candidate.source}: ${run.error.message}`);
|
|
210
|
+
// 0 = the hook's answer; 2 = the hook's own policy deny (Cursor's channel).
|
|
211
|
+
if (run.status === 0 || run.status === 2)
|
|
212
|
+
return { stdout: String(run.stdout ?? ""), exitCode: run.status, warning: null };
|
|
213
|
+
return failOpen(`${candidate.source} exited ${run.status ?? `on ${run.signal ?? "a signal"}`}`);
|
|
214
|
+
}
|
|
215
|
+
return failOpen(errors.join("; "));
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Workit's state dir (workit-paths.mjs, the one resolver).
|
|
219
|
+
* @param {NodeJS.ProcessEnv} env */
|
|
220
|
+
export const workitStateDir = (env) => stateDir(env);
|
|
221
|
+
|
|
222
|
+
/** File the launcher touches on every run, so `workit doctor` can tell when
|
|
223
|
+
* Cursor started sessions but never ran a Workit hook. */
|
|
224
|
+
export const CURSOR_HOOK_HEARTBEAT = "cursor-hook-last-run";
|
|
225
|
+
|
|
226
|
+
/** Best-effort heartbeat; a read-only state dir never blocks a hook.
|
|
227
|
+
* @param {NodeJS.ProcessEnv} env @param {string} bin */
|
|
228
|
+
export const touchCursorHookHeartbeat = (env, bin) => {
|
|
229
|
+
try {
|
|
230
|
+
const dir = workitStateDir(env);
|
|
231
|
+
mkdirSync(dir, { recursive: true });
|
|
232
|
+
writeFileSync(
|
|
233
|
+
path.join(dir, CURSOR_HOOK_HEARTBEAT),
|
|
234
|
+
`${JSON.stringify({ at: new Date().toISOString(), bin })}\n`,
|
|
235
|
+
);
|
|
236
|
+
} catch {
|
|
237
|
+
/* heartbeat is diagnostic only */
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** A hook-runs failure row, as workit-core hooks/hook-runs.ts recordHookFailure
|
|
242
|
+
* writes it (`<state>/hook-runs/cursor__<event>__failed.json`), so `workit
|
|
243
|
+
* doctor` sees a hook that could not answer. Best effort.
|
|
244
|
+
* @param {NodeJS.ProcessEnv} env @param {unknown} event @param {string} cause */
|
|
245
|
+
export const recordCursorHookFailure = (env, event, cause) => {
|
|
246
|
+
try {
|
|
247
|
+
const name = typeof event === "string" && event ? event : "unknown";
|
|
248
|
+
const safe = (/** @type {string} */ part) => part.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64);
|
|
249
|
+
const file = path.join(workitStateDir(env), "hook-runs", `cursor__${safe(name)}__failed.json`);
|
|
250
|
+
let count = 0;
|
|
251
|
+
try {
|
|
252
|
+
const previous = JSON.parse(readFileSync(file, "utf8")).count;
|
|
253
|
+
if (typeof previous === "number" && Number.isFinite(previous)) count = previous;
|
|
254
|
+
} catch {
|
|
255
|
+
/* first failure, or a torn file */
|
|
256
|
+
}
|
|
257
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
258
|
+
writeFileSync(
|
|
259
|
+
file,
|
|
260
|
+
JSON.stringify({
|
|
261
|
+
host: "cursor",
|
|
262
|
+
event: name,
|
|
263
|
+
at: new Date().toISOString(),
|
|
264
|
+
count: count + 1,
|
|
265
|
+
failure: String(cause).slice(0, 500),
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
} catch {
|
|
269
|
+
/* diagnostic only */
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** What the agent reads at sessionStart when the Workit hook could not run.
|
|
274
|
+
* @param {string} reason */
|
|
275
|
+
export const cursorHookOffNotice = (reason) =>
|
|
276
|
+
`Workit hooks are off: the Cursor hook could not run (${reason}). Branch policy, task context and verifier sessions are off until it does. Run \`workit upgrade\` to reinstall the plugin, then \`workit doctor\` (check hook_node runs each installed hook).`;
|
package/hooks/launch.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cursor hook launcher. hooks-cursor.json runs
|
|
3
|
+
// `node "${CURSOR_PLUGIN_ROOT}/hooks/launch.mjs" <bin>` for every event. This
|
|
4
|
+
// file is committed plain JavaScript, so it runs from a Marketplace git checkout
|
|
5
|
+
// (no build, no dependencies) as well as from an installed plugin copy.
|
|
6
|
+
//
|
|
7
|
+
// Resolution order (first match wins; no step ever resolves `@latest`):
|
|
8
|
+
// 1. local: the plugin's own bundled hook, <plugin>/dist/<entry>.js;
|
|
9
|
+
// 2. local: `<bin>` on PATH (a global `@workit-dev/cursor` install);
|
|
10
|
+
// 3. npx-pinned: `npx -y --prefer-offline --package=<pkg>@<plugin version>`,
|
|
11
|
+
// the version read from this plugin's package.json;
|
|
12
|
+
// 4. missing: nothing runnable.
|
|
13
|
+
//
|
|
14
|
+
// Fail-open, never silent: a launcher or infrastructure failure (a Node below
|
|
15
|
+
// the floor, nothing to run, spawn failure, npx network error, timeout, a
|
|
16
|
+
// crash) exits 0, writes one `[workit] Cursor hook unavailable: …` line on
|
|
17
|
+
// stderr and records a hook-runs failure row for `workit doctor`; sessionStart
|
|
18
|
+
// answers with additional_context naming the cause and the fix, every other
|
|
19
|
+
// event with `{}`. Only a policy deny from the hook itself (exit 2 with
|
|
20
|
+
// Cursor's deny JSON) blocks.
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import {
|
|
25
|
+
cursorHookOffNotice,
|
|
26
|
+
recordCursorHookFailure,
|
|
27
|
+
resolveCursorHookLaunch,
|
|
28
|
+
runCursorHookLaunch,
|
|
29
|
+
touchCursorHookHeartbeat,
|
|
30
|
+
} from "./launch-runtime.mjs";
|
|
31
|
+
import { floorMessage, floorOfRange, handleBelowFloor } from "./node-resolve.mjs";
|
|
32
|
+
import { migrateLegacyDirs } from "./workit-paths.mjs";
|
|
33
|
+
|
|
34
|
+
const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
35
|
+
|
|
36
|
+
// One-time move of a pre-XDG state or config dir, before the heartbeat writes the new one.
|
|
37
|
+
if (process.argv[2] !== "--probe") migrateLegacyDirs();
|
|
38
|
+
|
|
39
|
+
// A Node below this package's own engines minimum cannot run the bundled hook.
|
|
40
|
+
// The launcher then re-runs itself on a Node that can (hooks/node-resolve.mjs:
|
|
41
|
+
// $WORKIT_NODE, the node `workit setup` recorded, a version-manager scan), with
|
|
42
|
+
// stdin, stdout, stderr and the exit code passed through; WORKIT_HOOK_REEXEC
|
|
43
|
+
// stops a second hop. With none found it fails open with one clear line, and a
|
|
44
|
+
// sessionStart tells the agent once that the hooks are off.
|
|
45
|
+
// WORKIT_HOOK_NODE_VERSION stands in for process.versions.node (tests). An
|
|
46
|
+
// unreadable minimum never blocks.
|
|
47
|
+
const nodeFloor = () => {
|
|
48
|
+
try {
|
|
49
|
+
const range = JSON.parse(readFileSync(path.join(pluginRoot, "package.json"), "utf8")).engines
|
|
50
|
+
?.node;
|
|
51
|
+
const floor = floorOfRange(range);
|
|
52
|
+
return floor ? { range, floor } : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const isSessionStart = (event) =>
|
|
58
|
+
event === "sessionStart" || process.argv[2] === "workit-cursor-session-start";
|
|
59
|
+
const hookNode = nodeFloor();
|
|
60
|
+
const foundNode = process.env.WORKIT_HOOK_NODE_VERSION || process.versions.node;
|
|
61
|
+
const tooOld = hookNode ? floorMessage(foundNode, hookNode.floor, hookNode.range) : null;
|
|
62
|
+
// --probe reports on stdout and never reads stdin: it keeps its own answer.
|
|
63
|
+
if (tooOld && hookNode && process.argv[2] !== "--probe")
|
|
64
|
+
process.exit(
|
|
65
|
+
await handleBelowFloor({
|
|
66
|
+
host: "Cursor",
|
|
67
|
+
range: hookNode.range,
|
|
68
|
+
floor: hookNode.floor,
|
|
69
|
+
found: foundNode,
|
|
70
|
+
script: fileURLToPath(import.meta.url),
|
|
71
|
+
argv: process.argv.slice(2),
|
|
72
|
+
env: process.env,
|
|
73
|
+
isStart: (event) => {
|
|
74
|
+
recordCursorHookFailure(
|
|
75
|
+
process.env,
|
|
76
|
+
isSessionStart(event) ? "sessionStart" : event,
|
|
77
|
+
tooOld,
|
|
78
|
+
);
|
|
79
|
+
return isSessionStart(event);
|
|
80
|
+
},
|
|
81
|
+
notice: (additional_context) => ({ additional_context }),
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// `--probe <bin>` (for `workit doctor`): run one no-op preCompact through
|
|
86
|
+
// the candidate Cursor would use and print which one it was and how it went.
|
|
87
|
+
if (process.argv[2] === "--probe") {
|
|
88
|
+
const candidates = resolveCursorHookLaunch({
|
|
89
|
+
root: pluginRoot,
|
|
90
|
+
bin: process.argv[3] ?? "",
|
|
91
|
+
env: process.env,
|
|
92
|
+
});
|
|
93
|
+
const run = runCursorHookLaunch({
|
|
94
|
+
candidates: candidates.slice(0, 1),
|
|
95
|
+
payload: JSON.stringify({ hook_event_name: "preCompact", workspace_roots: [] }),
|
|
96
|
+
env: process.env,
|
|
97
|
+
});
|
|
98
|
+
const first = candidates[0];
|
|
99
|
+
process.stdout.write(
|
|
100
|
+
`${JSON.stringify({ mode: first?.mode ?? "missing", source: first?.source ?? null, version: first?.version ?? null, error: run.warning })}\n`,
|
|
101
|
+
);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const bin = process.argv[2] ?? "";
|
|
106
|
+
touchCursorHookHeartbeat(process.env, bin);
|
|
107
|
+
let payload = "";
|
|
108
|
+
for await (const chunk of process.stdin) payload += String(chunk);
|
|
109
|
+
let result;
|
|
110
|
+
try {
|
|
111
|
+
result = runCursorHookLaunch({
|
|
112
|
+
candidates: resolveCursorHookLaunch({ root: pluginRoot, bin, env: process.env }),
|
|
113
|
+
payload,
|
|
114
|
+
env: process.env,
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
result = {
|
|
118
|
+
stdout: "{}\n",
|
|
119
|
+
exitCode: 0,
|
|
120
|
+
warning: `[workit] Cursor hook unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (result.warning) {
|
|
124
|
+
process.stderr.write(`${result.warning}\n`);
|
|
125
|
+
let event;
|
|
126
|
+
try {
|
|
127
|
+
event = JSON.parse(payload).hook_event_name;
|
|
128
|
+
} catch {
|
|
129
|
+
event = undefined;
|
|
130
|
+
}
|
|
131
|
+
const reason = result.warning.replace(/^\[workit\] Cursor hook unavailable: /, "");
|
|
132
|
+
recordCursorHookFailure(process.env, isSessionStart(event) ? "sessionStart" : event, reason);
|
|
133
|
+
if (isSessionStart(event))
|
|
134
|
+
result.stdout = `${JSON.stringify({ additional_context: cursorHookOffNotice(reason) })}\n`;
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(result.stdout);
|
|
137
|
+
process.exitCode = result.exitCode;
|