@jeffober/thatch 0.1.4 → 0.1.6
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/README.md +60 -9
- package/bin/thatch +167 -46
- package/docs/dev/README.md +73 -18
- package/docs/dev/gotchas.md +99 -0
- package/docs/dev/mcp-parity.md +113 -57
- package/docs/dev/setup-and-hooks.md +133 -0
- package/docs/dev/skills.md +109 -0
- package/docs/qa/README.md +9 -0
- package/docs/qa/use-cases/README.md +16 -0
- package/docs/qa/use-cases/UC-005-setup-install.md +34 -0
- package/docs/qa/use-cases/UC-006-cursor-hook-lifecycle.md +31 -0
- package/docs/qa/use-cases/UC-007-recall-nudge.md +31 -0
- package/docs/qa/use-cases/UC-008-hygiene-heartbeat.md +26 -0
- package/docs/qa/use-cases/UC-009-flush-tools-tiers.md +31 -0
- package/docs/qa/use-cases/UC-010-prime.md +25 -0
- package/docs/qa/use-cases/UC-011-write-time-similarity-warning.md +24 -0
- package/docs/qa/use-cases/UC-012-model-migration.md +28 -0
- package/docs/qa/use-cases/UC-013-compaction-context.md +25 -0
- package/docs/qa/use-cases/UC-014-skill-install-drift.md +28 -0
- package/docs/qa/use-cases/UC-015-env-override-matrix.md +28 -0
- package/docs/qa/use-cases/UC-016-concurrent-session-isolation.md +36 -0
- package/docs/qa/use-cases/UC-017-buffer-tool-vs-buffer-batch.md +27 -0
- package/docs/user/README.md +101 -4
- package/package.json +3 -2
- package/src/db.ts +19 -5
- package/src/git.ts +1 -1
- package/src/index.ts +70 -21
- package/src/mcp.ts +19 -0
- package/src/prompts.ts +111 -0
- package/src/setup.ts +171 -8
- package/src/sideband.ts +207 -0
- package/src/skills.ts +732 -9
package/src/index.ts
CHANGED
|
@@ -8,15 +8,26 @@ import {
|
|
|
8
8
|
systemPrompt,
|
|
9
9
|
compactionContext,
|
|
10
10
|
sessionStartReminder,
|
|
11
|
+
recallNudge,
|
|
12
|
+
type NudgeMatch,
|
|
11
13
|
} from "./prompts";
|
|
12
14
|
import { ExtractionPipeline } from "./extraction";
|
|
13
|
-
import { installSkills } from "./skills";
|
|
15
|
+
import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
|
|
14
16
|
import { hygieneReport } from "./hygiene";
|
|
15
17
|
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// V1 server export — tools, prompt injection, session hooks
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
19
21
|
|
|
22
|
+
// Minimum cosine score for the prompt-aware recall nudge. Lower than
|
|
23
|
+
// findDuplicates' 0.85 (near-dupes) because "relates to" is a weaker signal
|
|
24
|
+
// than "duplicate." Tunable via THATCH_RECALL_THRESHOLD.
|
|
25
|
+
const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55");
|
|
26
|
+
|
|
27
|
+
// Prompts shorter than this skip the recall nudge — trivially short prompts
|
|
28
|
+
// like "yes" or "ok" match too broadly to be useful.
|
|
29
|
+
const MIN_PROMPT_LEN = 10;
|
|
30
|
+
|
|
20
31
|
export const server: Plugin = async ({ client, worktree }) => {
|
|
21
32
|
// The opencode server's cwd is wherever the server happened to start;
|
|
22
33
|
// `worktree` is the project this plugin instance actually serves.
|
|
@@ -34,7 +45,10 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
34
45
|
// worktree would mutate the user's repo (untracked files in git status).
|
|
35
46
|
// A failed install degrades the nudge workflow but must not kill the plugin.
|
|
36
47
|
try {
|
|
37
|
-
installSkills(join(configHome, "opencode", "skills")
|
|
48
|
+
installSkills(join(configHome, "opencode", "skills"), [
|
|
49
|
+
...SHARED_SKILLS,
|
|
50
|
+
...OPENCODE_ONLY_SKILLS,
|
|
51
|
+
]);
|
|
38
52
|
} catch (err) {
|
|
39
53
|
console.error(`[thatch] skill install failed: ${err}`);
|
|
40
54
|
}
|
|
@@ -69,27 +83,62 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
69
83
|
});
|
|
70
84
|
},
|
|
71
85
|
|
|
72
|
-
// 4. Per-message nudge —
|
|
73
|
-
//
|
|
74
|
-
//
|
|
86
|
+
// 4. Per-message nudge — two priority tiers:
|
|
87
|
+
// a. Extraction nudge: prior tool interactions are queued for fact
|
|
88
|
+
// extraction (carries the JSON payload for thatch-fact-extractor).
|
|
89
|
+
// b. Recall nudge: the user's prompt semantically matches existing
|
|
90
|
+
// memories — surface that prior knowledge exists before the agent
|
|
91
|
+
// responds. Uses the in-process warm model (no sideband needed).
|
|
75
92
|
"chat.message": async (input, output) => {
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
if (extraction.pending(input.sessionID)) {
|
|
94
|
+
const batch = extraction.flush(input.sessionID);
|
|
95
|
+
const payload = extraction.buildPayload(batch, repo);
|
|
96
|
+
const text =
|
|
97
|
+
`[thatch] ${batch.length} recent tool interactions are queued for fact extraction. ` +
|
|
98
|
+
`Use the skill tool to load thatch-fact-extractor, then use thatch_memory_remember ` +
|
|
99
|
+
`to save any new durable facts from this payload:\n${payload}`;
|
|
100
|
+
|
|
101
|
+
output.parts.push({
|
|
102
|
+
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
103
|
+
sessionID: input.sessionID,
|
|
104
|
+
messageID: input.messageID ?? output.message.id,
|
|
105
|
+
type: "text",
|
|
106
|
+
text,
|
|
107
|
+
synthetic: true,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
84
111
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
112
|
+
// No extraction pending — try the prompt-aware recall nudge. Extract
|
|
113
|
+
// the user's prompt text from the message parts, embed it with the
|
|
114
|
+
// warm in-process model, and search for matches. Best-effort: any
|
|
115
|
+
// failure (no text, model not loaded, empty store) silently skips.
|
|
116
|
+
try {
|
|
117
|
+
const promptText = (output.parts as any[])
|
|
118
|
+
.filter((p) => p.type === "text" && !p.synthetic)
|
|
119
|
+
.map((p) => p.text)
|
|
120
|
+
.join(" ");
|
|
121
|
+
if (promptText.length < MIN_PROMPT_LEN) return;
|
|
122
|
+
|
|
123
|
+
const embedding = await model.queryEmbed(promptText);
|
|
124
|
+
const results = db.search([repo, "global"], embedding, { limit: 5 });
|
|
125
|
+
const matches: NudgeMatch[] = results
|
|
126
|
+
.filter((r) => r._score >= RECALL_THRESHOLD)
|
|
127
|
+
.map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
|
|
128
|
+
|
|
129
|
+
if (matches.length === 0) return;
|
|
130
|
+
|
|
131
|
+
output.parts.push({
|
|
132
|
+
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
133
|
+
sessionID: input.sessionID,
|
|
134
|
+
messageID: input.messageID ?? output.message.id,
|
|
135
|
+
type: "text",
|
|
136
|
+
text: recallNudge(matches),
|
|
137
|
+
synthetic: true,
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error(`[thatch] recall nudge failed: ${err}`);
|
|
141
|
+
}
|
|
93
142
|
},
|
|
94
143
|
|
|
95
144
|
// 5. Session-start reminder, carrying the hygiene heartbeat. Hygiene is
|
package/src/mcp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { ThatchDB } from "./db";
|
|
|
3
3
|
import { BgeEmbeddingModel } from "./embeddings";
|
|
4
4
|
import { detectRepo } from "./git";
|
|
5
5
|
import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
|
|
6
|
+
import { SidebandServer, sidebandSocketPath } from "./sideband";
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// JSON-RPC 2.0 types
|
|
@@ -79,6 +80,10 @@ function compileTools(): Map<string, CompiledTool> {
|
|
|
79
80
|
* model loads lazily on the first tool call that needs it, so the server
|
|
80
81
|
* starts instantly and only pays the ~34 MB download cost when a memory is
|
|
81
82
|
* actually written or recalled.
|
|
83
|
+
*
|
|
84
|
+
* A Unix domain socket sideband is opened at startup so that one-shot hook
|
|
85
|
+
* processes (thatch flush-tools) can ask the warm MCP server to embed a
|
|
86
|
+
* prompt and search for matches without loading the model themselves.
|
|
82
87
|
*/
|
|
83
88
|
export async function runMcpServer(): Promise<void> {
|
|
84
89
|
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
|
|
@@ -94,6 +99,19 @@ export async function runMcpServer(): Promise<void> {
|
|
|
94
99
|
const ctx: CoreContext = { db, model, defaultStore: repo };
|
|
95
100
|
const tools = compileTools();
|
|
96
101
|
|
|
102
|
+
// The sideband socket lets one-shot hook processes (flush-tools) ask the
|
|
103
|
+
// warm MCP server to embed a prompt and search for matches — without
|
|
104
|
+
// loading the ~34 MB model themselves. If the socket can't be opened
|
|
105
|
+
// (permissions, tmpdir issues), the MCP server still works; only the
|
|
106
|
+
// prompt-aware recall nudge degrades.
|
|
107
|
+
const sockPath = sidebandSocketPath(dbPath);
|
|
108
|
+
const sideband = new SidebandServer(sockPath, model, db);
|
|
109
|
+
try {
|
|
110
|
+
sideband.start();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(`[thatch] sideband socket failed: ${err}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
// Read stdin line by line. Each line is a complete JSON-RPC message.
|
|
98
116
|
const decoder = new TextDecoder();
|
|
99
117
|
let buf = "";
|
|
@@ -123,6 +141,7 @@ export async function runMcpServer(): Promise<void> {
|
|
|
123
141
|
}
|
|
124
142
|
}
|
|
125
143
|
|
|
144
|
+
sideband.stop();
|
|
126
145
|
db.close();
|
|
127
146
|
}
|
|
128
147
|
|
package/src/prompts.ts
CHANGED
|
@@ -202,3 +202,114 @@ export function claudeExtractionNudge(count: number, payload: string): string {
|
|
|
202
202
|
`Load the thatch-fact-extractor skill, then use mcp__thatch__memory_remember ` +
|
|
203
203
|
`to save any new durable facts from this payload:\n${payload}`;
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Static instructions for Cursor's AGENTS.md. Same contract as
|
|
208
|
+
* claudeInstructions() — the host loads this at every session start, and the
|
|
209
|
+
* repo store name is auto-detected at runtime by the MCP server. Tool names
|
|
210
|
+
* use the same mcp__thatch__ prefix since Cursor discovers MCP tools via the
|
|
211
|
+
* same protocol.
|
|
212
|
+
*/
|
|
213
|
+
export function cursorInstructions(): string {
|
|
214
|
+
return `# Persistence
|
|
215
|
+
|
|
216
|
+
Thatch provides persistent memory across Cursor sessions. Use it to persist
|
|
217
|
+
knowledge so future sessions can build on what you've already learned.
|
|
218
|
+
Tools are prefixed in Cursor: \`mcp__thatch__memory_remember\`,
|
|
219
|
+
\`mcp__thatch__memory_recall\`, \`mcp__thatch__memory_list\`,
|
|
220
|
+
\`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
|
|
221
|
+
\`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
|
|
222
|
+
\`mcp__thatch__dedup_mark_checked\`.
|
|
223
|
+
Bare names used below for readability.
|
|
224
|
+
|
|
225
|
+
## Stores
|
|
226
|
+
|
|
227
|
+
- \`global\`: user preferences, personality, system environment (shared across projects)
|
|
228
|
+
- Per-project stores: project-specific knowledge (auto-detected from git remote)
|
|
229
|
+
- Use \`mcp__thatch__store_list\` to see active stores
|
|
230
|
+
|
|
231
|
+
Memories can be scoped to a git branch via the \`branch\` param (feature design,
|
|
232
|
+
WIP, PR status). Unscoped memories are project-wide and always included in search.
|
|
233
|
+
|
|
234
|
+
## Session Startup
|
|
235
|
+
|
|
236
|
+
1. \`memory_recall\` "user preferences and personality" (global store)
|
|
237
|
+
2. \`memory_recall\` "project architecture and conventions" (project store)
|
|
238
|
+
3. If on a non-main branch, \`memory_recall\` with the branch name
|
|
239
|
+
4. \`memory_list\` and \`store_list\` to see what's available
|
|
240
|
+
|
|
241
|
+
Before diving into code, \`memory_recall\` with a query relevant to the area
|
|
242
|
+
you're working in. Prior sessions may have already investigated it.
|
|
243
|
+
|
|
244
|
+
## When to Write
|
|
245
|
+
|
|
246
|
+
**One signal is enough.** Do not wait for confirmation.
|
|
247
|
+
Save immediately on: feedback, preferences, corrections, emotional signals,
|
|
248
|
+
new project knowledge, conventions, architectural decisions, investigation results.
|
|
249
|
+
|
|
250
|
+
## What to Store
|
|
251
|
+
|
|
252
|
+
- **Global store**: user observations, agent personality, system environment
|
|
253
|
+
- **Project store**: repo organization, infrastructure, languages/frameworks,
|
|
254
|
+
component relationships, conventions, operational playbooks
|
|
255
|
+
- **Branch-scoped**: branch purpose, feature design decisions, PR status,
|
|
256
|
+
temporary workarounds
|
|
257
|
+
- **Insights**: ALWAYS save non-obvious gotchas, patterns, and architectural
|
|
258
|
+
lessons discovered during implementation. Never prompt the user; just do it.
|
|
259
|
+
|
|
260
|
+
## How to Write
|
|
261
|
+
|
|
262
|
+
- \`memory_recall\` first to check for duplicates. Use overwrite: true to
|
|
263
|
+
update rather than creating a new entry.
|
|
264
|
+
- One topic per memory. Write for a future instance with zero current context.
|
|
265
|
+
- Confidence 1-2: single signal. 5-6: moderate. 9: explicitly stated.
|
|
266
|
+
10: hard constraint.
|
|
267
|
+
|
|
268
|
+
## What NOT to Store
|
|
269
|
+
|
|
270
|
+
Session-specific context, incomplete/unverified info, anything already in
|
|
271
|
+
AGENTS.md, speculative conclusions.
|
|
272
|
+
|
|
273
|
+
## Explicit Requests
|
|
274
|
+
|
|
275
|
+
"Remember X" — save immediately.
|
|
276
|
+
"Forget X" — \`memory_recall\` to find it, then \`memory_forget\`.`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Prompt-aware recall nudge — fires when the user's prompt semantically
|
|
281
|
+
// matches existing memories. Injected via opencode's chat.message hook
|
|
282
|
+
// (in-process, warm model) or via the sideband socket for Claude Code/Cursor
|
|
283
|
+
// (flush-tools CLI subcommand).
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
export interface NudgeMatch {
|
|
287
|
+
label: string;
|
|
288
|
+
score: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The recall nudge for the opencode plugin path. Uses the thatch_ tool prefix
|
|
293
|
+
* since opencode discovers tools directly from the plugin registration.
|
|
294
|
+
*/
|
|
295
|
+
export function recallNudge(matches: NudgeMatch[]): string {
|
|
296
|
+
return formatRecallNudge(matches, "thatch_memory_recall");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The recall nudge for the Claude Code / Cursor hook path. Uses the bare tool
|
|
301
|
+
* name (memory_recall) since the mcp__thatch__ prefix is verbose and the
|
|
302
|
+
* CLAUDE.md / AGENTS.md instructions already establish the bare-name convention.
|
|
303
|
+
*/
|
|
304
|
+
export function claudeRecallNudge(matches: NudgeMatch[]): string {
|
|
305
|
+
return formatRecallNudge(matches, "memory_recall");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
|
|
309
|
+
const n = matches.length;
|
|
310
|
+
const word = n === 1 ? "memory" : "memories";
|
|
311
|
+
const verb = n === 1 ? "relates" : "relate";
|
|
312
|
+
const labels = matches.slice(0, 2).map((m) => `"${m.label}"`).join(", ");
|
|
313
|
+
const etc = n > 2 ? ", etc." : "";
|
|
314
|
+
return `[thatch] ${n} ${word} ${verb} to this prompt (${labels}${etc}). Use ${toolName} before responding.`;
|
|
315
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { claudeInstructions } from "./prompts";
|
|
3
|
+
import { claudeInstructions, cursorInstructions } from "./prompts";
|
|
4
4
|
import { installSkills, type SkillFile } from "./skills";
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
@@ -91,17 +91,26 @@ function writeMcpConfig(path: string, thatchBin: string): void {
|
|
|
91
91
|
// ---------------------------------------------------------------------------
|
|
92
92
|
|
|
93
93
|
const THATCH_MARKER = "# Persistence\n\nThatch provides persistent memory across Claude Code sessions.";
|
|
94
|
+
const THATCH_END_MARKER = '"Forget X" — `memory_recall` to find it, then `memory_forget`.';
|
|
95
|
+
const CURSOR_MARKER = "# Persistence\n\nThatch provides persistent memory across Cursor sessions.";
|
|
96
|
+
const CURSOR_END_MARKER = THATCH_END_MARKER;
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Idempotently append (or replace) a block of instructions in a markdown file.
|
|
100
|
+
* The block is delimited by startMarker and endMarker so re-running setup
|
|
101
|
+
* updates drifted content without clobbering surrounding text.
|
|
102
|
+
*/
|
|
103
|
+
function appendBlock(
|
|
104
|
+
path: string,
|
|
105
|
+
instructions: string,
|
|
106
|
+
startMarker: string,
|
|
107
|
+
endMarker: string,
|
|
108
|
+
): void {
|
|
98
109
|
if (existsSync(path)) {
|
|
99
110
|
const existing = readFileSync(path, "utf8");
|
|
100
|
-
if (existing.includes(
|
|
101
|
-
|
|
102
|
-
const startIdx = existing.indexOf("# Persistence\n\nThatch provides persistent memory across Claude Code sessions.");
|
|
111
|
+
if (existing.includes(startMarker)) {
|
|
112
|
+
const startIdx = existing.indexOf(startMarker);
|
|
103
113
|
if (startIdx >= 0) {
|
|
104
|
-
const endMarker = '"Forget X" — `memory_recall` to find it, then `memory_forget`.';
|
|
105
114
|
const endIdx = existing.indexOf(endMarker, startIdx);
|
|
106
115
|
if (endIdx >= 0) {
|
|
107
116
|
const afterEnd = endIdx + endMarker.length;
|
|
@@ -122,6 +131,14 @@ function appendInstructions(path: string): void {
|
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
function appendInstructions(path: string): void {
|
|
135
|
+
appendBlock(path, claudeInstructions() + "\n", THATCH_MARKER, THATCH_END_MARKER);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function appendCursorInstructions(path: string): void {
|
|
139
|
+
appendBlock(path, cursorInstructions() + "\n", CURSOR_MARKER, CURSOR_END_MARKER);
|
|
140
|
+
}
|
|
141
|
+
|
|
125
142
|
// ---------------------------------------------------------------------------
|
|
126
143
|
// settings.json — hook installation
|
|
127
144
|
// ---------------------------------------------------------------------------
|
|
@@ -257,3 +274,149 @@ export function setupClaudeCode(
|
|
|
257
274
|
: null,
|
|
258
275
|
};
|
|
259
276
|
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Cursor setup — .cursor/mcp.json, AGENTS.md, .cursor/hooks.json, skills
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve the Cursor config directory. Cursor stores config under `~/.cursor`
|
|
284
|
+
* by default. No documented env override exists (unlike Claude Code's
|
|
285
|
+
* CLAUDE_CONFIG_DIR), but we honor a hypothetical CURSOR_CONFIG_DIR for
|
|
286
|
+
* symmetry and forward-compatibility.
|
|
287
|
+
*/
|
|
288
|
+
function cursorConfigDir(homeDir: string): string {
|
|
289
|
+
return process.env.CURSOR_CONFIG_DIR ?? join(homeDir, ".cursor");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface CursorSetupPaths {
|
|
293
|
+
mcpConfigPath: string;
|
|
294
|
+
agentsMdPath: string;
|
|
295
|
+
hooksPath: string;
|
|
296
|
+
skillsDir: string;
|
|
297
|
+
global: boolean;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function resolveCursorPaths(global: boolean, projectDir: string, homeDir: string): CursorSetupPaths {
|
|
301
|
+
const configDir = cursorConfigDir(homeDir);
|
|
302
|
+
const skillsDir = join(configDir, "skills");
|
|
303
|
+
|
|
304
|
+
if (global) {
|
|
305
|
+
return {
|
|
306
|
+
// Cursor's global MCP config is a simple file — no equivalent of
|
|
307
|
+
// `claude mcp add --scope user`. Writing ~/.cursor/mcp.json is enough.
|
|
308
|
+
mcpConfigPath: join(configDir, "mcp.json"),
|
|
309
|
+
agentsMdPath: join(configDir, "AGENTS.md"),
|
|
310
|
+
hooksPath: join(configDir, "hooks.json"),
|
|
311
|
+
skillsDir,
|
|
312
|
+
global: true,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
mcpConfigPath: join(projectDir, ".cursor", "mcp.json"),
|
|
318
|
+
agentsMdPath: join(projectDir, "AGENTS.md"),
|
|
319
|
+
hooksPath: join(projectDir, ".cursor", "hooks.json"),
|
|
320
|
+
skillsDir,
|
|
321
|
+
global: false,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Cursor hooks use a flat format (no nesting, no `type` field):
|
|
327
|
+
* { "version": 1, "hooks": { "event": [{ "command": "..." }] } }
|
|
328
|
+
*/
|
|
329
|
+
interface CursorHooksJson {
|
|
330
|
+
version: number;
|
|
331
|
+
hooks: {
|
|
332
|
+
[event: string]: { command: string }[];
|
|
333
|
+
};
|
|
334
|
+
[key: string]: unknown;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function writeCursorHooks(path: string, thatchBin: string): void {
|
|
338
|
+
let config: CursorHooksJson = { version: 1, hooks: {} };
|
|
339
|
+
if (existsSync(path)) {
|
|
340
|
+
try {
|
|
341
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
342
|
+
} catch {
|
|
343
|
+
// Corrupt — start fresh.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!config.version) config.version = 1;
|
|
347
|
+
if (!config.hooks) config.hooks = {};
|
|
348
|
+
|
|
349
|
+
// --json makes reminder output { additional_context: "..." } which Cursor
|
|
350
|
+
// injects into the session. Without --json, stdout is plain text (Claude
|
|
351
|
+
// Code style) and Cursor would not parse it.
|
|
352
|
+
const sessionStartCmd = `${thatchBin} reminder --json`;
|
|
353
|
+
// postToolUse fires per-tool (Cursor has no PostToolBatch equivalent).
|
|
354
|
+
// Silent — no stdout — so the agent loop is not delayed.
|
|
355
|
+
const bufferCmd = `${thatchBin} buffer-tool`;
|
|
356
|
+
// beforeSubmitPrompt is Cursor's UserPromptSubmit equivalent. Drains the
|
|
357
|
+
// queue and prints the extraction nudge as JSON additional_context.
|
|
358
|
+
const flushCmd = `${thatchBin} flush-tools --json`;
|
|
359
|
+
|
|
360
|
+
config.hooks.sessionStart = replaceCursorThatchHooks(config.hooks.sessionStart ?? [], sessionStartCmd);
|
|
361
|
+
config.hooks.postToolUse = replaceCursorThatchHooks(config.hooks.postToolUse ?? [], bufferCmd);
|
|
362
|
+
config.hooks.beforeSubmitPrompt = replaceCursorThatchHooks(config.hooks.beforeSubmitPrompt ?? [], flushCmd);
|
|
363
|
+
|
|
364
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
365
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function replaceCursorThatchHooks(
|
|
369
|
+
entries: { command: string }[],
|
|
370
|
+
command: string,
|
|
371
|
+
): { command: string }[] {
|
|
372
|
+
const filtered = entries.filter((e) => !e.command?.includes("thatch"));
|
|
373
|
+
filtered.push({ command });
|
|
374
|
+
return filtered;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface CursorSetupResult {
|
|
378
|
+
mcpConfig: string;
|
|
379
|
+
agentsMd: string;
|
|
380
|
+
hooks: string;
|
|
381
|
+
skills: SkillFile[];
|
|
382
|
+
global: boolean;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Installs thatch into Cursor. Writes .cursor/mcp.json (or ~/.cursor/mcp.json
|
|
387
|
+
* for global), appends instructions to AGENTS.md, installs sessionStart +
|
|
388
|
+
* postToolUse + beforeSubmitPrompt hooks in .cursor/hooks.json, and installs
|
|
389
|
+
* skill files. All operations are idempotent.
|
|
390
|
+
*
|
|
391
|
+
* Cursor's global MCP config is a simple file write (unlike Claude Code which
|
|
392
|
+
* needs `claude mcp add --scope user`). Skills install to ~/.cursor/skills/
|
|
393
|
+
* (auto-discovered by Cursor, which also reads ~/.claude/skills/ for compat).
|
|
394
|
+
*
|
|
395
|
+
* @param thatchBin Absolute path to the thatch binary
|
|
396
|
+
* @param global Whether to install globally or project-locally
|
|
397
|
+
* @param projectDir The project directory (used for project-local installs)
|
|
398
|
+
* @param homeDir Home directory (defaults to os.homedir())
|
|
399
|
+
*/
|
|
400
|
+
export function setupCursor(
|
|
401
|
+
thatchBin: string,
|
|
402
|
+
global: boolean,
|
|
403
|
+
projectDir: string,
|
|
404
|
+
homeDir?: string,
|
|
405
|
+
): CursorSetupResult {
|
|
406
|
+
const { homedir } = require("node:os");
|
|
407
|
+
const home = homeDir ?? homedir();
|
|
408
|
+
const paths = resolveCursorPaths(global, projectDir, home);
|
|
409
|
+
|
|
410
|
+
writeMcpConfig(paths.mcpConfigPath, thatchBin);
|
|
411
|
+
appendCursorInstructions(paths.agentsMdPath);
|
|
412
|
+
writeCursorHooks(paths.hooksPath, thatchBin);
|
|
413
|
+
const skills = installClaudeSkills(paths.skillsDir);
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
mcpConfig: paths.mcpConfigPath,
|
|
417
|
+
agentsMd: paths.agentsMdPath,
|
|
418
|
+
hooks: paths.hooksPath,
|
|
419
|
+
skills,
|
|
420
|
+
global,
|
|
421
|
+
};
|
|
422
|
+
}
|