context-mode 1.0.14 → 1.0.16
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +99 -22
- package/build/adapters/claude-code/index.d.ts +4 -0
- package/build/adapters/claude-code/index.js +56 -40
- package/build/adapters/cursor/config.d.ts +4 -0
- package/build/adapters/cursor/config.js +4 -0
- package/build/adapters/cursor/hooks.d.ts +40 -0
- package/build/adapters/cursor/hooks.js +53 -0
- package/build/adapters/cursor/index.d.ts +40 -0
- package/build/adapters/cursor/index.js +413 -0
- package/build/adapters/detect.d.ts +1 -0
- package/build/adapters/detect.js +19 -0
- package/build/adapters/opencode/index.js +3 -1
- package/build/adapters/types.d.ts +1 -1
- package/build/cli.js +5 -0
- package/build/executor.js +2 -0
- package/build/opencode-plugin.js +8 -0
- package/build/server.js +35 -3
- package/build/session/db.js +21 -0
- package/build/sync/batcher.d.ts +23 -0
- package/build/sync/batcher.js +74 -0
- package/build/sync/cloud-post.d.ts +12 -0
- package/build/sync/cloud-post.js +38 -0
- package/build/sync/config.d.ts +4 -0
- package/build/sync/config.js +64 -0
- package/build/sync/index.d.ts +12 -0
- package/build/sync/index.js +55 -0
- package/build/sync/sanitizer.d.ts +13 -0
- package/build/sync/sanitizer.js +86 -0
- package/build/sync/sender.d.ts +15 -0
- package/build/sync/sender.js +30 -0
- package/build/sync/types.d.ts +31 -0
- package/build/sync/types.js +1 -0
- package/cli.bundle.mjs +2 -2
- package/configs/cursor/hooks.json +16 -0
- package/configs/cursor/mcp.json +7 -0
- package/hooks/core/formatters.mjs +16 -0
- package/hooks/core/routing.mjs +18 -4
- package/hooks/cursor/posttooluse.mjs +70 -0
- package/hooks/cursor/pretooluse.mjs +26 -0
- package/hooks/cursor/sessionstart.mjs +97 -0
- package/hooks/formatters/cursor.mjs +37 -0
- package/hooks/gemini-cli/sessionstart.mjs +7 -0
- package/hooks/session-helpers.mjs +22 -0
- package/hooks/vscode-copilot/sessionstart.mjs +7 -0
- package/package.json +3 -2
- package/server.bundle.mjs +2 -2
- package/skills/context-mode/SKILL.md +7 -9
- package/skills/ctx-cloud-setup/SKILL.md +98 -0
- package/skills/ctx-cloud-status/SKILL.md +96 -0
- package/skills/ctx-doctor/SKILL.md +1 -1
- package/skills/ctx-stats/SKILL.md +1 -1
- package/skills/ctx-upgrade/SKILL.md +1 -1
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/cursor — Cursor platform adapter.
|
|
3
|
+
*
|
|
4
|
+
* Native Cursor hooks use lower-camel hook names and flat command entries in
|
|
5
|
+
* `.cursor/hooks.json` / `~/.cursor/hooks.json`.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, accessSync, chmodSync, constants, existsSync, } from "node:fs";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { resolve, join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { HOOK_TYPES as CURSOR_HOOK_NAMES, HOOK_SCRIPTS as CURSOR_HOOK_SCRIPTS, PRE_TOOL_USE_MATCHER_PATTERN, REQUIRED_HOOKS, OPTIONAL_HOOKS, isContextModeHook, buildHookCommand, } from "./hooks.js";
|
|
13
|
+
const CURSOR_ENTERPRISE_HOOKS_PATH = "/Library/Application Support/Cursor/hooks.json";
|
|
14
|
+
export class CursorAdapter {
|
|
15
|
+
name = "Cursor";
|
|
16
|
+
paradigm = "json-stdio";
|
|
17
|
+
capabilities = {
|
|
18
|
+
preToolUse: true,
|
|
19
|
+
postToolUse: true,
|
|
20
|
+
preCompact: false,
|
|
21
|
+
sessionStart: false,
|
|
22
|
+
canModifyArgs: true,
|
|
23
|
+
canModifyOutput: false,
|
|
24
|
+
canInjectSessionContext: true,
|
|
25
|
+
};
|
|
26
|
+
parsePreToolUseInput(raw) {
|
|
27
|
+
const input = raw;
|
|
28
|
+
return {
|
|
29
|
+
toolName: input.tool_name ?? "",
|
|
30
|
+
toolInput: input.tool_input ?? {},
|
|
31
|
+
sessionId: this.extractSessionId(input),
|
|
32
|
+
projectDir: this.getProjectDir(input),
|
|
33
|
+
raw,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
parsePostToolUseInput(raw) {
|
|
37
|
+
const input = raw;
|
|
38
|
+
return {
|
|
39
|
+
toolName: input.tool_name ?? "",
|
|
40
|
+
toolInput: input.tool_input ?? {},
|
|
41
|
+
toolOutput: input.tool_output ?? input.error_message,
|
|
42
|
+
isError: Boolean(input.error_message),
|
|
43
|
+
sessionId: this.extractSessionId(input),
|
|
44
|
+
projectDir: this.getProjectDir(input),
|
|
45
|
+
raw,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
parseSessionStartInput(raw) {
|
|
49
|
+
const input = raw;
|
|
50
|
+
const rawSource = input.source ?? input.trigger ?? "startup";
|
|
51
|
+
let source;
|
|
52
|
+
switch (rawSource) {
|
|
53
|
+
case "compact":
|
|
54
|
+
source = "compact";
|
|
55
|
+
break;
|
|
56
|
+
case "resume":
|
|
57
|
+
source = "resume";
|
|
58
|
+
break;
|
|
59
|
+
case "clear":
|
|
60
|
+
source = "clear";
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
source = "startup";
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
sessionId: this.extractSessionId(input),
|
|
67
|
+
source,
|
|
68
|
+
projectDir: this.getProjectDir(input),
|
|
69
|
+
raw,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
formatPreToolUseResponse(response) {
|
|
73
|
+
if (response.decision === "deny") {
|
|
74
|
+
return {
|
|
75
|
+
permission: "deny",
|
|
76
|
+
user_message: response.reason ?? "Blocked by context-mode hook",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (response.decision === "modify" && response.updatedInput) {
|
|
80
|
+
return { updated_input: response.updatedInput };
|
|
81
|
+
}
|
|
82
|
+
if (response.decision === "context" && response.additionalContext) {
|
|
83
|
+
return { agent_message: response.additionalContext };
|
|
84
|
+
}
|
|
85
|
+
if (response.decision === "ask") {
|
|
86
|
+
return {
|
|
87
|
+
permission: "ask",
|
|
88
|
+
user_message: response.reason ?? "Action requires user confirmation (security policy)",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Cursor rejects empty stdout as "no valid response", so adapter callers
|
|
92
|
+
// need the same explicit no-op payload the hook scripts emit at runtime.
|
|
93
|
+
return { agent_message: "" };
|
|
94
|
+
}
|
|
95
|
+
formatPostToolUseResponse(response) {
|
|
96
|
+
// Cursor rejects empty stdout as "no valid response", so emit a no-op
|
|
97
|
+
// additional_context payload when there is nothing to inject.
|
|
98
|
+
return { additional_context: response.additionalContext ?? "" };
|
|
99
|
+
}
|
|
100
|
+
formatSessionStartResponse(response) {
|
|
101
|
+
// SessionStart follows the same rule: always emit valid JSON, even when
|
|
102
|
+
// the payload is effectively a no-op.
|
|
103
|
+
return { additional_context: response.context ?? "" };
|
|
104
|
+
}
|
|
105
|
+
getSettingsPath() {
|
|
106
|
+
return resolve(".cursor", "hooks.json");
|
|
107
|
+
}
|
|
108
|
+
getSessionDir() {
|
|
109
|
+
const dir = join(homedir(), ".cursor", "context-mode", "sessions");
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
return dir;
|
|
112
|
+
}
|
|
113
|
+
getSessionDBPath(projectDir) {
|
|
114
|
+
const hash = createHash("sha256")
|
|
115
|
+
.update(projectDir)
|
|
116
|
+
.digest("hex")
|
|
117
|
+
.slice(0, 16);
|
|
118
|
+
return join(this.getSessionDir(), `${hash}.db`);
|
|
119
|
+
}
|
|
120
|
+
getSessionEventsPath(projectDir) {
|
|
121
|
+
const hash = createHash("sha256")
|
|
122
|
+
.update(projectDir)
|
|
123
|
+
.digest("hex")
|
|
124
|
+
.slice(0, 16);
|
|
125
|
+
return join(this.getSessionDir(), `${hash}-events.md`);
|
|
126
|
+
}
|
|
127
|
+
generateHookConfig(_pluginRoot) {
|
|
128
|
+
const hooks = {
|
|
129
|
+
[CURSOR_HOOK_NAMES.PRE_TOOL_USE]: [
|
|
130
|
+
{
|
|
131
|
+
type: "command",
|
|
132
|
+
command: buildHookCommand(CURSOR_HOOK_NAMES.PRE_TOOL_USE),
|
|
133
|
+
matcher: PRE_TOOL_USE_MATCHER_PATTERN,
|
|
134
|
+
loop_limit: null,
|
|
135
|
+
failClosed: false,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
[CURSOR_HOOK_NAMES.POST_TOOL_USE]: [
|
|
139
|
+
{
|
|
140
|
+
type: "command",
|
|
141
|
+
command: buildHookCommand(CURSOR_HOOK_NAMES.POST_TOOL_USE),
|
|
142
|
+
loop_limit: null,
|
|
143
|
+
failClosed: false,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
[CURSOR_HOOK_NAMES.SESSION_START]: [
|
|
147
|
+
{
|
|
148
|
+
type: "command",
|
|
149
|
+
command: buildHookCommand(CURSOR_HOOK_NAMES.SESSION_START),
|
|
150
|
+
loop_limit: null,
|
|
151
|
+
failClosed: false,
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
return hooks;
|
|
156
|
+
}
|
|
157
|
+
readSettings() {
|
|
158
|
+
for (const configPath of this.getCandidateHookConfigPaths()) {
|
|
159
|
+
try {
|
|
160
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
161
|
+
return JSON.parse(raw);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
writeSettings(settings) {
|
|
170
|
+
const configPath = this.getSettingsPath();
|
|
171
|
+
mkdirSync(resolve(".cursor"), { recursive: true });
|
|
172
|
+
writeFileSync(configPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
173
|
+
}
|
|
174
|
+
validateHooks(_pluginRoot) {
|
|
175
|
+
const results = [];
|
|
176
|
+
const loaded = this.loadNativeHookConfig();
|
|
177
|
+
if (!loaded) {
|
|
178
|
+
results.push({
|
|
179
|
+
check: "Native hook config",
|
|
180
|
+
status: "fail",
|
|
181
|
+
message: "No readable native Cursor hook config found in .cursor/hooks.json or ~/.cursor/hooks.json",
|
|
182
|
+
fix: "context-mode upgrade",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const hooks = loaded.config.hooks ?? {};
|
|
187
|
+
results.push({
|
|
188
|
+
check: "Native hook config",
|
|
189
|
+
status: "pass",
|
|
190
|
+
message: `Loaded ${loaded.path}`,
|
|
191
|
+
});
|
|
192
|
+
for (const hookType of REQUIRED_HOOKS) {
|
|
193
|
+
const entries = hooks[hookType];
|
|
194
|
+
const hasHook = Array.isArray(entries)
|
|
195
|
+
&& entries.some((entry) => isContextModeHook(entry, hookType));
|
|
196
|
+
results.push({
|
|
197
|
+
check: hookType,
|
|
198
|
+
status: hasHook ? "pass" : "fail",
|
|
199
|
+
message: hasHook
|
|
200
|
+
? `${hookType} hook configured`
|
|
201
|
+
: `${hookType} hook not configured in ${loaded.path}`,
|
|
202
|
+
fix: hasHook ? undefined : "context-mode upgrade",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
for (const hookType of OPTIONAL_HOOKS) {
|
|
206
|
+
const entries = hooks[hookType];
|
|
207
|
+
const hasHook = Array.isArray(entries)
|
|
208
|
+
&& entries.some((entry) => isContextModeHook(entry, hookType));
|
|
209
|
+
results.push({
|
|
210
|
+
check: hookType,
|
|
211
|
+
status: hasHook ? "pass" : "warn",
|
|
212
|
+
message: hasHook
|
|
213
|
+
? `${hookType} hook configured`
|
|
214
|
+
: `${hookType} hook missing — session event capture will be reduced`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (existsSync(CURSOR_ENTERPRISE_HOOKS_PATH)) {
|
|
219
|
+
results.push({
|
|
220
|
+
check: "Enterprise hook config",
|
|
221
|
+
status: "warn",
|
|
222
|
+
message: "Enterprise Cursor hook config detected at /Library/Application Support/Cursor/hooks.json (read-only informational layer)",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (this.hasClaudeCompatibilityHooks()) {
|
|
226
|
+
results.push({
|
|
227
|
+
check: "Claude compatibility",
|
|
228
|
+
status: "warn",
|
|
229
|
+
message: "Claude-compatible hooks detected; native Cursor hooks are the supported configuration",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return results;
|
|
233
|
+
}
|
|
234
|
+
checkPluginRegistration() {
|
|
235
|
+
const mcpPaths = [resolve(".cursor", "mcp.json"), join(homedir(), ".cursor", "mcp.json")];
|
|
236
|
+
for (const configPath of mcpPaths) {
|
|
237
|
+
try {
|
|
238
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
239
|
+
const config = JSON.parse(raw);
|
|
240
|
+
const servers = (config.mcpServers ?? config.servers);
|
|
241
|
+
if (!servers)
|
|
242
|
+
continue;
|
|
243
|
+
const hasContextMode = Object.entries(servers).some(([name, value]) => {
|
|
244
|
+
if (name.includes("context-mode"))
|
|
245
|
+
return true;
|
|
246
|
+
if (!value || typeof value !== "object")
|
|
247
|
+
return false;
|
|
248
|
+
const server = value;
|
|
249
|
+
return server.command === "context-mode";
|
|
250
|
+
});
|
|
251
|
+
if (hasContextMode) {
|
|
252
|
+
return {
|
|
253
|
+
check: "MCP registration",
|
|
254
|
+
status: "pass",
|
|
255
|
+
message: `context-mode found in ${configPath}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
check: "MCP registration",
|
|
265
|
+
status: "warn",
|
|
266
|
+
message: "Could not find context-mode in .cursor/mcp.json or ~/.cursor/mcp.json",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
getInstalledVersion() {
|
|
270
|
+
try {
|
|
271
|
+
const output = execSync("cursor --version", {
|
|
272
|
+
encoding: "utf-8",
|
|
273
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
274
|
+
}).trim();
|
|
275
|
+
return output.split(/\r?\n/)[0] || "unknown";
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return "not installed";
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
configureAllHooks(_pluginRoot) {
|
|
282
|
+
const settings = this.readSettings() ?? { version: 1, hooks: {} };
|
|
283
|
+
const hooks = (settings.hooks ?? {});
|
|
284
|
+
const changes = [];
|
|
285
|
+
this.upsertHookEntry(hooks, CURSOR_HOOK_NAMES.PRE_TOOL_USE, {
|
|
286
|
+
type: "command",
|
|
287
|
+
command: buildHookCommand(CURSOR_HOOK_NAMES.PRE_TOOL_USE),
|
|
288
|
+
matcher: PRE_TOOL_USE_MATCHER_PATTERN,
|
|
289
|
+
loop_limit: null,
|
|
290
|
+
failClosed: false,
|
|
291
|
+
}, changes);
|
|
292
|
+
this.upsertHookEntry(hooks, CURSOR_HOOK_NAMES.POST_TOOL_USE, {
|
|
293
|
+
type: "command",
|
|
294
|
+
command: buildHookCommand(CURSOR_HOOK_NAMES.POST_TOOL_USE),
|
|
295
|
+
loop_limit: null,
|
|
296
|
+
failClosed: false,
|
|
297
|
+
}, changes);
|
|
298
|
+
this.upsertHookEntry(hooks, CURSOR_HOOK_NAMES.SESSION_START, {
|
|
299
|
+
type: "command",
|
|
300
|
+
command: buildHookCommand(CURSOR_HOOK_NAMES.SESSION_START),
|
|
301
|
+
loop_limit: null,
|
|
302
|
+
failClosed: false,
|
|
303
|
+
}, changes);
|
|
304
|
+
settings.version = 1;
|
|
305
|
+
settings.hooks = hooks;
|
|
306
|
+
this.writeSettings(settings);
|
|
307
|
+
changes.push(`Wrote native Cursor hooks to ${this.getSettingsPath()}`);
|
|
308
|
+
return changes;
|
|
309
|
+
}
|
|
310
|
+
backupSettings() {
|
|
311
|
+
const settingsPath = this.getSettingsPath();
|
|
312
|
+
try {
|
|
313
|
+
accessSync(settingsPath, constants.R_OK);
|
|
314
|
+
const backupPath = settingsPath + ".bak";
|
|
315
|
+
copyFileSync(settingsPath, backupPath);
|
|
316
|
+
return backupPath;
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
setHookPermissions(pluginRoot) {
|
|
323
|
+
const set = [];
|
|
324
|
+
const hooksDir = join(pluginRoot, "hooks", "cursor");
|
|
325
|
+
for (const scriptName of Object.values(CURSOR_HOOK_SCRIPTS)) {
|
|
326
|
+
const scriptPath = resolve(hooksDir, scriptName);
|
|
327
|
+
try {
|
|
328
|
+
accessSync(scriptPath, constants.R_OK);
|
|
329
|
+
chmodSync(scriptPath, 0o755);
|
|
330
|
+
set.push(scriptPath);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* skip missing scripts */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return set;
|
|
337
|
+
}
|
|
338
|
+
updatePluginRegistry(_pluginRoot, _version) {
|
|
339
|
+
// Cursor manages extensions and native hooks internally.
|
|
340
|
+
}
|
|
341
|
+
getRoutingInstructionsConfig() {
|
|
342
|
+
return {
|
|
343
|
+
fileName: "AGENTS.md",
|
|
344
|
+
globalPath: "",
|
|
345
|
+
projectRelativePath: "AGENTS.md",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
writeRoutingInstructions(_projectDir, _pluginRoot) {
|
|
349
|
+
// Native Cursor hook support ships independently from any instruction-file story.
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
getCandidateHookConfigPaths() {
|
|
353
|
+
const paths = [this.getSettingsPath(), join(homedir(), ".cursor", "hooks.json")];
|
|
354
|
+
if (process.platform === "darwin") {
|
|
355
|
+
paths.push(CURSOR_ENTERPRISE_HOOKS_PATH);
|
|
356
|
+
}
|
|
357
|
+
return paths;
|
|
358
|
+
}
|
|
359
|
+
getProjectDir(input) {
|
|
360
|
+
return input.cwd
|
|
361
|
+
|| input.workspace_roots?.[0]
|
|
362
|
+
|| process.env.CURSOR_CWD
|
|
363
|
+
|| process.cwd();
|
|
364
|
+
}
|
|
365
|
+
extractSessionId(input) {
|
|
366
|
+
if (input.conversation_id)
|
|
367
|
+
return input.conversation_id;
|
|
368
|
+
if (input.session_id)
|
|
369
|
+
return input.session_id;
|
|
370
|
+
if (process.env.CURSOR_SESSION_ID)
|
|
371
|
+
return process.env.CURSOR_SESSION_ID;
|
|
372
|
+
if (process.env.CURSOR_TRACE_ID)
|
|
373
|
+
return process.env.CURSOR_TRACE_ID;
|
|
374
|
+
return `pid-${process.ppid}`;
|
|
375
|
+
}
|
|
376
|
+
loadNativeHookConfig() {
|
|
377
|
+
for (const configPath of this.getCandidateHookConfigPaths()) {
|
|
378
|
+
try {
|
|
379
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
380
|
+
const config = JSON.parse(raw);
|
|
381
|
+
if (config && typeof config === "object") {
|
|
382
|
+
return { path: configPath, config };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
hasClaudeCompatibilityHooks() {
|
|
392
|
+
const compatPaths = [
|
|
393
|
+
resolve(".claude", "settings.json"),
|
|
394
|
+
resolve(".claude", "settings.local.json"),
|
|
395
|
+
join(homedir(), ".claude", "settings.json"),
|
|
396
|
+
];
|
|
397
|
+
return compatPaths.some((configPath) => existsSync(configPath));
|
|
398
|
+
}
|
|
399
|
+
upsertHookEntry(hooks, hookType, entry, changes) {
|
|
400
|
+
const existingRaw = hooks[hookType];
|
|
401
|
+
const existing = Array.isArray(existingRaw) ? [...existingRaw] : [];
|
|
402
|
+
const idx = existing.findIndex((candidate) => isContextModeHook(candidate, hookType));
|
|
403
|
+
if (idx >= 0) {
|
|
404
|
+
existing[idx] = entry;
|
|
405
|
+
changes.push(`Updated existing ${hookType} hook entry`);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
existing.push(entry);
|
|
409
|
+
changes.push(`Added ${hookType} hook entry`);
|
|
410
|
+
}
|
|
411
|
+
hooks[hookType] = existing;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - Gemini CLI: GEMINI_PROJECT_DIR (hooks), GEMINI_CLI (MCP) | ~/.gemini/
|
|
12
12
|
* - OpenCode: OPENCODE, OPENCODE_PID | ~/.config/opencode/
|
|
13
13
|
* - Codex CLI: CODEX_CI, CODEX_THREAD_ID | ~/.codex/
|
|
14
|
+
* - Cursor: CURSOR_TRACE_ID (MCP), CURSOR_CLI (terminal) | ~/.cursor/
|
|
14
15
|
* - VS Code Copilot: VSCODE_PID, VSCODE_CWD | ~/.vscode/
|
|
15
16
|
*/
|
|
16
17
|
import type { PlatformId, DetectionSignal, HookAdapter } from "./types.js";
|
package/build/adapters/detect.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - Gemini CLI: GEMINI_PROJECT_DIR (hooks), GEMINI_CLI (MCP) | ~/.gemini/
|
|
12
12
|
* - OpenCode: OPENCODE, OPENCODE_PID | ~/.config/opencode/
|
|
13
13
|
* - Codex CLI: CODEX_CI, CODEX_THREAD_ID | ~/.codex/
|
|
14
|
+
* - Cursor: CURSOR_TRACE_ID (MCP), CURSOR_CLI (terminal) | ~/.cursor/
|
|
14
15
|
* - VS Code Copilot: VSCODE_PID, VSCODE_CWD | ~/.vscode/
|
|
15
16
|
*/
|
|
16
17
|
import { existsSync } from "node:fs";
|
|
@@ -49,6 +50,13 @@ export function detectPlatform() {
|
|
|
49
50
|
reason: "CODEX_CI or CODEX_THREAD_ID env var set",
|
|
50
51
|
};
|
|
51
52
|
}
|
|
53
|
+
if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_CLI) {
|
|
54
|
+
return {
|
|
55
|
+
platform: "cursor",
|
|
56
|
+
confidence: "high",
|
|
57
|
+
reason: "CURSOR_TRACE_ID or CURSOR_CLI env var set",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
52
60
|
if (process.env.VSCODE_PID || process.env.VSCODE_CWD) {
|
|
53
61
|
return {
|
|
54
62
|
platform: "vscode-copilot",
|
|
@@ -79,6 +87,13 @@ export function detectPlatform() {
|
|
|
79
87
|
reason: "~/.codex/ directory exists",
|
|
80
88
|
};
|
|
81
89
|
}
|
|
90
|
+
if (existsSync(resolve(home, ".cursor"))) {
|
|
91
|
+
return {
|
|
92
|
+
platform: "cursor",
|
|
93
|
+
confidence: "medium",
|
|
94
|
+
reason: "~/.cursor/ directory exists",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
82
97
|
if (existsSync(resolve(home, ".config", "opencode"))) {
|
|
83
98
|
return {
|
|
84
99
|
platform: "opencode",
|
|
@@ -120,6 +135,10 @@ export async function getAdapter(platform) {
|
|
|
120
135
|
const { VSCodeCopilotAdapter } = await import("./vscode-copilot/index.js");
|
|
121
136
|
return new VSCodeCopilotAdapter();
|
|
122
137
|
}
|
|
138
|
+
case "cursor": {
|
|
139
|
+
const { CursorAdapter } = await import("./cursor/index.js");
|
|
140
|
+
return new CursorAdapter();
|
|
141
|
+
}
|
|
123
142
|
default: {
|
|
124
143
|
// Unsupported platform — fall back to Claude Code adapter
|
|
125
144
|
// (MCP server works everywhere, hooks may not)
|
|
@@ -196,10 +196,11 @@ export class OpenCodeAdapter {
|
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
198
|
readSettings() {
|
|
199
|
-
// Try
|
|
199
|
+
// Try project-local paths first, then global config
|
|
200
200
|
const paths = [
|
|
201
201
|
resolve("opencode.json"),
|
|
202
202
|
resolve(".opencode", "opencode.json"),
|
|
203
|
+
join(homedir(), ".config", "opencode", "opencode.json"),
|
|
203
204
|
];
|
|
204
205
|
for (const configPath of paths) {
|
|
205
206
|
try {
|
|
@@ -322,6 +323,7 @@ export class OpenCodeAdapter {
|
|
|
322
323
|
const paths = [
|
|
323
324
|
resolve("opencode.json"),
|
|
324
325
|
resolve(".opencode", "opencode.json"),
|
|
326
|
+
join(homedir(), ".config", "opencode", "opencode.json"),
|
|
325
327
|
];
|
|
326
328
|
for (const configPath of paths) {
|
|
327
329
|
try {
|
|
@@ -206,7 +206,7 @@ export interface DiagnosticResult {
|
|
|
206
206
|
fix?: string;
|
|
207
207
|
}
|
|
208
208
|
/** Supported platform identifiers. */
|
|
209
|
-
export type PlatformId = "claude-code" | "gemini-cli" | "opencode" | "codex" | "vscode-copilot" | "unknown";
|
|
209
|
+
export type PlatformId = "claude-code" | "gemini-cli" | "opencode" | "codex" | "vscode-copilot" | "cursor" | "unknown";
|
|
210
210
|
/** Detection signal used to identify which platform is running. */
|
|
211
211
|
export interface DetectionSignal {
|
|
212
212
|
/** Platform identifier. */
|
package/build/cli.js
CHANGED
|
@@ -45,6 +45,11 @@ const HOOK_MAP = {
|
|
|
45
45
|
precompact: "hooks/vscode-copilot/precompact.mjs",
|
|
46
46
|
sessionstart: "hooks/vscode-copilot/sessionstart.mjs",
|
|
47
47
|
},
|
|
48
|
+
"cursor": {
|
|
49
|
+
pretooluse: "hooks/cursor/pretooluse.mjs",
|
|
50
|
+
posttooluse: "hooks/cursor/posttooluse.mjs",
|
|
51
|
+
sessionstart: "hooks/cursor/sessionstart.mjs",
|
|
52
|
+
},
|
|
48
53
|
};
|
|
49
54
|
async function hookDispatch(platform, event) {
|
|
50
55
|
// Suppress stderr at OS fd level — native C++ modules (better-sqlite3) write
|
package/build/executor.js
CHANGED
package/build/opencode-plugin.js
CHANGED
|
@@ -21,6 +21,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
21
21
|
import { SessionDB } from "./session/db.js";
|
|
22
22
|
import { extractEvents } from "./session/extract.js";
|
|
23
23
|
import { buildResumeSnapshot } from "./session/snapshot.js";
|
|
24
|
+
import { OpenCodeAdapter } from "./adapters/opencode/index.js";
|
|
24
25
|
// ── Helpers ───────────────────────────────────────────────
|
|
25
26
|
function getSessionDir() {
|
|
26
27
|
const dir = join(homedir(), ".config", "opencode", "context-mode", "sessions");
|
|
@@ -51,6 +52,13 @@ export const ContextModePlugin = async (ctx) => {
|
|
|
51
52
|
const db = new SessionDB({ dbPath: getDBPath(projectDir) });
|
|
52
53
|
const sessionId = randomUUID();
|
|
53
54
|
db.ensureSession(sessionId, projectDir);
|
|
55
|
+
// Auto-write AGENTS.md on startup for OpenCode projects
|
|
56
|
+
try {
|
|
57
|
+
new OpenCodeAdapter().writeRoutingInstructions(projectDir, resolve(buildDir, ".."));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// best effort — never break plugin init
|
|
61
|
+
}
|
|
54
62
|
// Clean up old sessions on startup (replaces SessionStart hook)
|
|
55
63
|
db.cleanupOldSessions(0);
|
|
56
64
|
return {
|
package/build/server.js
CHANGED
|
@@ -13,7 +13,8 @@ import { ContentStore, cleanupStaleDBs } from "./store.js";
|
|
|
13
13
|
import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, evaluateFilePath, } from "./security.js";
|
|
14
14
|
import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
|
|
15
15
|
import { classifyNonZeroExit } from "./exit-classify.js";
|
|
16
|
-
|
|
16
|
+
import { initSync, flushSync, destroySync } from "./sync/index.js";
|
|
17
|
+
const VERSION = "1.0.16";
|
|
17
18
|
// Prevent silent server death from unhandled async errors
|
|
18
19
|
process.on("unhandledRejection", (err) => {
|
|
19
20
|
process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
|
|
@@ -1454,10 +1455,22 @@ async function main() {
|
|
|
1454
1455
|
executor.cleanupBackgrounded();
|
|
1455
1456
|
if (_store)
|
|
1456
1457
|
_store.cleanup();
|
|
1458
|
+
try {
|
|
1459
|
+
destroySync();
|
|
1460
|
+
}
|
|
1461
|
+
catch { /* sync cleanup is best-effort */ }
|
|
1462
|
+
};
|
|
1463
|
+
const gracefulShutdown = async () => {
|
|
1464
|
+
try {
|
|
1465
|
+
await flushSync();
|
|
1466
|
+
}
|
|
1467
|
+
catch { /* flush is best-effort */ }
|
|
1468
|
+
shutdown();
|
|
1469
|
+
process.exit(0);
|
|
1457
1470
|
};
|
|
1458
1471
|
process.on("exit", shutdown);
|
|
1459
|
-
process.on("SIGINT", () => {
|
|
1460
|
-
process.on("SIGTERM", () => {
|
|
1472
|
+
process.on("SIGINT", () => { gracefulShutdown(); });
|
|
1473
|
+
process.on("SIGTERM", () => { gracefulShutdown(); });
|
|
1461
1474
|
const transport = new StdioServerTransport();
|
|
1462
1475
|
await server.connect(transport);
|
|
1463
1476
|
// Write routing instructions for hookless platforms (e.g. Codex CLI)
|
|
@@ -1474,6 +1487,25 @@ async function main() {
|
|
|
1474
1487
|
}
|
|
1475
1488
|
}
|
|
1476
1489
|
catch { /* best effort — don't block server startup */ }
|
|
1490
|
+
// Initialize cloud sync (fire-and-forget, never blocks server)
|
|
1491
|
+
try {
|
|
1492
|
+
const syncProjectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
|
|
1493
|
+
const syncSessionId = createHash("sha256").update(syncProjectDir + ":" + Date.now()).digest("hex").slice(0, 16);
|
|
1494
|
+
let gitRemote;
|
|
1495
|
+
try {
|
|
1496
|
+
const { execSync } = await import("node:child_process");
|
|
1497
|
+
gitRemote = execSync("git remote get-url origin", {
|
|
1498
|
+
cwd: syncProjectDir,
|
|
1499
|
+
timeout: 3000,
|
|
1500
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1501
|
+
}).toString().trim() || undefined;
|
|
1502
|
+
}
|
|
1503
|
+
catch { /* no git remote available */ }
|
|
1504
|
+
const syncOk = initSync(syncSessionId, syncProjectDir, gitRemote);
|
|
1505
|
+
if (syncOk)
|
|
1506
|
+
console.error("[context-mode] Cloud sync initialized");
|
|
1507
|
+
}
|
|
1508
|
+
catch { /* sync init failure must never block the plugin */ }
|
|
1477
1509
|
console.error(`Context Mode MCP server v${VERSION} running on stdio`);
|
|
1478
1510
|
console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
|
|
1479
1511
|
if (!hasBunRuntime()) {
|
package/build/session/db.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { SQLiteBase, defaultDBPath } from "../db-base.js";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { cloudPostEvent } from "../sync/cloud-post.js";
|
|
10
12
|
// ─────────────────────────────────────────────────────────
|
|
11
13
|
// Constants
|
|
12
14
|
// ─────────────────────────────────────────────────────────
|
|
@@ -188,6 +190,25 @@ export class SessionDB extends SQLiteBase {
|
|
|
188
190
|
this.stmt(S.updateMetaLastEvent).run(sessionId);
|
|
189
191
|
});
|
|
190
192
|
transaction();
|
|
193
|
+
// Fire-and-forget: POST event directly to cloud (works in short-lived hook processes)
|
|
194
|
+
try {
|
|
195
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
|
|
196
|
+
let gitRemote;
|
|
197
|
+
try {
|
|
198
|
+
gitRemote = execSync("git remote get-url origin", { cwd: projectDir, timeout: 2000 })
|
|
199
|
+
.toString().trim() || undefined;
|
|
200
|
+
}
|
|
201
|
+
catch { /* no git remote available */ }
|
|
202
|
+
cloudPostEvent({
|
|
203
|
+
type: event.type,
|
|
204
|
+
category: event.category,
|
|
205
|
+
priority: event.priority,
|
|
206
|
+
data: event.data,
|
|
207
|
+
source_hook: sourceHook,
|
|
208
|
+
created_at: new Date().toISOString(),
|
|
209
|
+
}, projectDir, sessionId, gitRemote);
|
|
210
|
+
}
|
|
211
|
+
catch { /* cloud sync must never break local event storage */ }
|
|
191
212
|
}
|
|
192
213
|
/**
|
|
193
214
|
* Retrieve events for a session with optional filtering.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SanitizedEvent } from "./types.js";
|
|
2
|
+
interface BatcherOpts {
|
|
3
|
+
batchSize: number;
|
|
4
|
+
flushIntervalMs: number;
|
|
5
|
+
maxBufferSize: number;
|
|
6
|
+
onFlush: (events: SanitizedEvent[]) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare class EventBatcher {
|
|
9
|
+
private opts;
|
|
10
|
+
private buffer;
|
|
11
|
+
private timer;
|
|
12
|
+
private inflightPromise;
|
|
13
|
+
private consecutiveFailures;
|
|
14
|
+
constructor(opts: BatcherOpts);
|
|
15
|
+
private startTimer;
|
|
16
|
+
push(event: SanitizedEvent): void;
|
|
17
|
+
flush(): Promise<void>;
|
|
18
|
+
resetCircuitBreaker(): void;
|
|
19
|
+
get bufferSize(): number;
|
|
20
|
+
get isCircuitOpen(): boolean;
|
|
21
|
+
destroy(): void;
|
|
22
|
+
}
|
|
23
|
+
export {};
|