@jeffober/thatch 0.1.5 → 0.1.8

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/src/index.ts CHANGED
@@ -8,6 +8,8 @@ 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
15
  import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
@@ -17,6 +19,15 @@ import { hygieneReport } from "./hygiene";
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.
@@ -30,6 +41,14 @@ export const server: Plugin = async ({ client, worktree }) => {
30
41
  const model = new BgeEmbeddingModel(modelName);
31
42
  const extraction = new ExtractionPipeline();
32
43
 
44
+ // Sessions currently being compacted. chat.message nudges are skipped while
45
+ // a session is in this set — the agent can't call tools during summary
46
+ // generation, so a recall or extraction nudge would cause a blocked-tool
47
+ // error. Cleared by experimental.compaction.autocontinue. If compaction
48
+ // fails, the entry leaks (graceful degradation: nudges stay off for that
49
+ // session, but no crash).
50
+ const compacting = new Set<string>();
51
+
33
52
  // Skills always install to the global opencode config — installing into the
34
53
  // worktree would mutate the user's repo (untracked files in git status).
35
54
  // A failed install degrades the nudge workflow but must not kill the plugin.
@@ -53,11 +72,20 @@ export const server: Plugin = async ({ client, worktree }) => {
53
72
  output.system.push(sys);
54
73
  },
55
74
 
56
- // 2. Compaction context — re-familiarizes after compaction.
57
- "experimental.session.compacting": async (_input, output) => {
75
+ // 2. Compaction context — re-familiarizes after compaction. The flag
76
+ // suppresses chat.message nudges during summary generation (tool
77
+ // calls are blocked there).
78
+ "experimental.session.compacting": async (input, output) => {
79
+ compacting.add(input.sessionID);
58
80
  output.context.push(compact);
59
81
  },
60
82
 
83
+ // 2b. Clear the compacting flag after compaction succeeds so chat.message
84
+ // nudges resume for the synthetic auto-continue turn and beyond.
85
+ "experimental.compaction.autocontinue": async (input) => {
86
+ compacting.delete(input.sessionID);
87
+ },
88
+
61
89
  // 3. Tool buffering — feeds the extraction nudge. Thatch's own tools are
62
90
  // excluded: extracting facts from memory operations would just echo the
63
91
  // store back into itself.
@@ -72,27 +100,67 @@ export const server: Plugin = async ({ client, worktree }) => {
72
100
  });
73
101
  },
74
102
 
75
- // 4. Per-message nudge — when interactions are queued, hand the agent the
76
- // actual payload; the thatch-fact-extractor skill's contract is "you will
77
- // be given a JSON payload", so the nudge must carry it.
103
+ // 4. Per-message nudge — two priority tiers:
104
+ // a. Extraction nudge: prior tool interactions are queued for fact
105
+ // extraction (carries the JSON payload for thatch-fact-extractor).
106
+ // b. Recall nudge: the user's prompt semantically matches existing
107
+ // memories — surface that prior knowledge exists before the agent
108
+ // responds. Uses the in-process warm model (no sideband needed).
109
+ //
110
+ // Skipped during compaction: the agent can't call tools while generating
111
+ // a summary, so a nudge that says "use thatch_memory_recall" triggers a
112
+ // blocked-tool error.
78
113
  "chat.message": async (input, output) => {
79
- if (!extraction.pending(input.sessionID)) return;
80
-
81
- const batch = extraction.flush(input.sessionID);
82
- const payload = extraction.buildPayload(batch, repo);
83
- const text =
84
- `[thatch] ${batch.length} recent tool interactions are queued for fact extraction. ` +
85
- `Use the skill tool to load thatch-fact-extractor, then use thatch_memory_remember ` +
86
- `to save any new durable facts from this payload:\n${payload}`;
114
+ if (compacting.has(input.sessionID)) return;
115
+ if (extraction.pending(input.sessionID)) {
116
+ const batch = extraction.flush(input.sessionID);
117
+ const payload = extraction.buildPayload(batch, repo);
118
+ const text =
119
+ `[thatch] ${batch.length} recent tool interactions are queued for fact extraction. ` +
120
+ `Use the skill tool to load thatch-fact-extractor, then use thatch_memory_remember ` +
121
+ `to save any new durable facts from this payload:\n${payload}`;
122
+
123
+ output.parts.push({
124
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
125
+ sessionID: input.sessionID,
126
+ messageID: input.messageID ?? output.message.id,
127
+ type: "text",
128
+ text,
129
+ synthetic: true,
130
+ });
131
+ return;
132
+ }
87
133
 
88
- output.parts.push({
89
- id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
90
- sessionID: input.sessionID,
91
- messageID: input.messageID ?? output.message.id,
92
- type: "text",
93
- text,
94
- synthetic: true,
95
- });
134
+ // No extraction pending — try the prompt-aware recall nudge. Extract
135
+ // the user's prompt text from the message parts, embed it with the
136
+ // warm in-process model, and search for matches. Best-effort: any
137
+ // failure (no text, model not loaded, empty store) silently skips.
138
+ try {
139
+ const promptText = (output.parts as any[])
140
+ .filter((p) => p.type === "text" && !p.synthetic)
141
+ .map((p) => p.text)
142
+ .join(" ");
143
+ if (promptText.length < MIN_PROMPT_LEN) return;
144
+
145
+ const embedding = await model.queryEmbed(promptText);
146
+ const results = db.search([repo, "global"], embedding, { limit: 5 });
147
+ const matches: NudgeMatch[] = results
148
+ .filter((r) => r._score >= RECALL_THRESHOLD)
149
+ .map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
150
+
151
+ if (matches.length === 0) return;
152
+
153
+ output.parts.push({
154
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
155
+ sessionID: input.sessionID,
156
+ messageID: input.messageID ?? output.message.id,
157
+ type: "text",
158
+ text: recallNudge(matches),
159
+ synthetic: true,
160
+ });
161
+ } catch (err) {
162
+ console.error(`[thatch] recall nudge failed: ${err}`);
163
+ }
96
164
  },
97
165
 
98
166
  // 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
@@ -64,9 +64,9 @@ CLAUDE.md or OPENCODE.md, speculative conclusions.
64
64
 
65
65
  export function compactionContext(repo: string): string {
66
66
  return `Thatch persistent memory is active with stores "${repo}" and "global".
67
- Use thatch_memory_recall to retrieve prior knowledge, thatch_memory_remember
68
- to persist important decisions, patterns, and conventions encountered so far.
69
- Include context about what's been learned and decided when summarizing.`;
67
+ When summarizing, preserve context about what's been learned and decided
68
+ this session — conventions, architectural decisions, and prior knowledge
69
+ retrieved from memory. This ensures continuity after compaction.`;
70
70
  }
71
71
 
72
72
  export function sessionStartReminder(repo: string, hygiene?: string | null): string {
@@ -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
- function appendInstructions(path: string): void {
96
- const instructions = claudeInstructions() + "\n";
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(THATCH_MARKER)) {
101
- // Already has thatch instructions — replace the block.
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
+ }