@velvetmonkey/flywheel-crank 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +154 -0
  3. package/dist/index.js +1195 -0
  4. package/package.json +49 -0
package/dist/index.js ADDED
@@ -0,0 +1,1195 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // src/tools/mutations.ts
8
+ import { z } from "zod";
9
+
10
+ // src/core/writer.ts
11
+ import fs from "fs/promises";
12
+ import path from "path";
13
+ import matter from "gray-matter";
14
+
15
+ // src/core/constants.ts
16
+ var HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
17
+
18
+ // src/core/writer.ts
19
+ function extractHeadings(content) {
20
+ const lines = content.split("\n");
21
+ const headings = [];
22
+ let inCodeBlock = false;
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const line = lines[i];
25
+ if (line.trim().startsWith("```")) {
26
+ inCodeBlock = !inCodeBlock;
27
+ continue;
28
+ }
29
+ if (inCodeBlock)
30
+ continue;
31
+ const match = line.match(HEADING_REGEX);
32
+ if (match) {
33
+ headings.push({
34
+ level: match[1].length,
35
+ text: match[2].trim(),
36
+ line: i
37
+ });
38
+ }
39
+ }
40
+ return headings;
41
+ }
42
+ function findSection(content, sectionName) {
43
+ const headings = extractHeadings(content);
44
+ const lines = content.split("\n");
45
+ const normalizedSearch = sectionName.replace(/^#+\s*/, "").trim().toLowerCase();
46
+ const headingIndex = headings.findIndex(
47
+ (h) => h.text.toLowerCase() === normalizedSearch
48
+ );
49
+ if (headingIndex === -1)
50
+ return null;
51
+ const targetHeading = headings[headingIndex];
52
+ const startLine = targetHeading.line;
53
+ const contentStartLine = startLine + 1;
54
+ let endLine = lines.length - 1;
55
+ for (let i = headingIndex + 1; i < headings.length; i++) {
56
+ if (headings[i].level <= targetHeading.level) {
57
+ endLine = headings[i].line - 1;
58
+ break;
59
+ }
60
+ }
61
+ return {
62
+ name: targetHeading.text,
63
+ level: targetHeading.level,
64
+ startLine,
65
+ endLine,
66
+ contentStartLine
67
+ };
68
+ }
69
+ function formatContent(content, format) {
70
+ const trimmed = content.trim();
71
+ switch (format) {
72
+ case "plain":
73
+ return trimmed;
74
+ case "bullet":
75
+ return `- ${trimmed}`;
76
+ case "task":
77
+ return `- [ ] ${trimmed}`;
78
+ case "numbered":
79
+ return `1. ${trimmed}`;
80
+ case "timestamp-bullet": {
81
+ const now = /* @__PURE__ */ new Date();
82
+ const hours = String(now.getHours()).padStart(2, "0");
83
+ const minutes = String(now.getMinutes()).padStart(2, "0");
84
+ return `- **${hours}:${minutes}** ${trimmed}`;
85
+ }
86
+ default:
87
+ return trimmed;
88
+ }
89
+ }
90
+ function insertInSection(content, section, newContent, position) {
91
+ const lines = content.split("\n");
92
+ const formattedContent = newContent.trim();
93
+ if (position === "prepend") {
94
+ lines.splice(section.contentStartLine, 0, formattedContent);
95
+ } else {
96
+ let insertLine = section.endLine + 1;
97
+ if (section.contentStartLine <= section.endLine) {
98
+ insertLine = section.endLine + 1;
99
+ } else {
100
+ insertLine = section.contentStartLine;
101
+ }
102
+ lines.splice(insertLine, 0, formattedContent);
103
+ }
104
+ return lines.join("\n");
105
+ }
106
+ function validatePath(vaultPath2, notePath) {
107
+ const resolvedVault = path.resolve(vaultPath2);
108
+ const resolvedNote = path.resolve(vaultPath2, notePath);
109
+ return resolvedNote.startsWith(resolvedVault);
110
+ }
111
+ async function readVaultFile(vaultPath2, notePath) {
112
+ if (!validatePath(vaultPath2, notePath)) {
113
+ throw new Error("Invalid path: path traversal not allowed");
114
+ }
115
+ const fullPath = path.join(vaultPath2, notePath);
116
+ const rawContent = await fs.readFile(fullPath, "utf-8");
117
+ const parsed = matter(rawContent);
118
+ return {
119
+ content: parsed.content,
120
+ frontmatter: parsed.data,
121
+ rawContent
122
+ };
123
+ }
124
+ async function writeVaultFile(vaultPath2, notePath, content, frontmatter) {
125
+ if (!validatePath(vaultPath2, notePath)) {
126
+ throw new Error("Invalid path: path traversal not allowed");
127
+ }
128
+ const fullPath = path.join(vaultPath2, notePath);
129
+ const output = matter.stringify(content, frontmatter);
130
+ await fs.writeFile(fullPath, output, "utf-8");
131
+ }
132
+ function removeFromSection(content, section, pattern, mode = "first", useRegex = false) {
133
+ const lines = content.split("\n");
134
+ const removedLines = [];
135
+ const indicesToRemove = [];
136
+ for (let i = section.contentStartLine; i <= section.endLine; i++) {
137
+ const line = lines[i];
138
+ let matches = false;
139
+ if (useRegex) {
140
+ const regex = new RegExp(pattern);
141
+ matches = regex.test(line);
142
+ } else {
143
+ matches = line.includes(pattern);
144
+ }
145
+ if (matches) {
146
+ indicesToRemove.push(i);
147
+ removedLines.push(line);
148
+ if (mode === "first")
149
+ break;
150
+ }
151
+ }
152
+ if (mode === "last" && indicesToRemove.length > 1) {
153
+ const lastIndex = indicesToRemove[indicesToRemove.length - 1];
154
+ const lastLine = removedLines[removedLines.length - 1];
155
+ indicesToRemove.length = 0;
156
+ removedLines.length = 0;
157
+ indicesToRemove.push(lastIndex);
158
+ removedLines.push(lastLine);
159
+ }
160
+ const sortedIndices = [...indicesToRemove].sort((a, b) => b - a);
161
+ for (const idx of sortedIndices) {
162
+ lines.splice(idx, 1);
163
+ }
164
+ return {
165
+ content: lines.join("\n"),
166
+ removedCount: indicesToRemove.length,
167
+ removedLines
168
+ };
169
+ }
170
+ function replaceInSection(content, section, search, replacement, mode = "first", useRegex = false) {
171
+ const lines = content.split("\n");
172
+ const originalLines = [];
173
+ const newLines = [];
174
+ const indicesToReplace = [];
175
+ for (let i = section.contentStartLine; i <= section.endLine; i++) {
176
+ const line = lines[i];
177
+ let matches = false;
178
+ if (useRegex) {
179
+ const regex = new RegExp(search);
180
+ matches = regex.test(line);
181
+ } else {
182
+ matches = line.includes(search);
183
+ }
184
+ if (matches) {
185
+ indicesToReplace.push(i);
186
+ originalLines.push(line);
187
+ if (mode === "first")
188
+ break;
189
+ }
190
+ }
191
+ if (mode === "last" && indicesToReplace.length > 1) {
192
+ const lastIndex = indicesToReplace[indicesToReplace.length - 1];
193
+ const lastLine = originalLines[originalLines.length - 1];
194
+ indicesToReplace.length = 0;
195
+ originalLines.length = 0;
196
+ indicesToReplace.push(lastIndex);
197
+ originalLines.push(lastLine);
198
+ }
199
+ for (const idx of indicesToReplace) {
200
+ const originalLine = lines[idx];
201
+ let newLine;
202
+ if (useRegex) {
203
+ const regex = new RegExp(search, "g");
204
+ newLine = originalLine.replace(regex, replacement);
205
+ } else {
206
+ newLine = originalLine.split(search).join(replacement);
207
+ }
208
+ lines[idx] = newLine;
209
+ newLines.push(newLine);
210
+ }
211
+ return {
212
+ content: lines.join("\n"),
213
+ replacedCount: indicesToReplace.length,
214
+ originalLines,
215
+ newLines
216
+ };
217
+ }
218
+
219
+ // src/core/git.ts
220
+ import { simpleGit, CheckRepoActions } from "simple-git";
221
+ import path2 from "path";
222
+ async function isGitRepo(vaultPath2) {
223
+ try {
224
+ const git = simpleGit(vaultPath2);
225
+ const isRepo = await git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
226
+ return isRepo;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+ async function commitChange(vaultPath2, filePath, messagePrefix) {
232
+ try {
233
+ const git = simpleGit(vaultPath2);
234
+ const isRepo = await isGitRepo(vaultPath2);
235
+ if (!isRepo) {
236
+ return {
237
+ success: false,
238
+ error: "Not a git repository"
239
+ };
240
+ }
241
+ await git.add(filePath);
242
+ const fileName = path2.basename(filePath);
243
+ const commitMessage = `${messagePrefix} Update ${fileName}`;
244
+ const result = await git.commit(commitMessage);
245
+ return {
246
+ success: true,
247
+ hash: result.commit
248
+ };
249
+ } catch (error) {
250
+ return {
251
+ success: false,
252
+ error: error instanceof Error ? error.message : String(error)
253
+ };
254
+ }
255
+ }
256
+ async function getLastCommit(vaultPath2) {
257
+ try {
258
+ const git = simpleGit(vaultPath2);
259
+ const isRepo = await isGitRepo(vaultPath2);
260
+ if (!isRepo) {
261
+ return null;
262
+ }
263
+ const log = await git.log({ maxCount: 1 });
264
+ if (!log.latest) {
265
+ return null;
266
+ }
267
+ return {
268
+ hash: log.latest.hash,
269
+ message: log.latest.message,
270
+ date: log.latest.date,
271
+ author: log.latest.author_name
272
+ };
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+ async function undoLastCommit(vaultPath2) {
278
+ try {
279
+ const git = simpleGit(vaultPath2);
280
+ const isRepo = await isGitRepo(vaultPath2);
281
+ if (!isRepo) {
282
+ return {
283
+ success: false,
284
+ message: "Not a git repository"
285
+ };
286
+ }
287
+ const lastCommit = await getLastCommit(vaultPath2);
288
+ if (!lastCommit) {
289
+ return {
290
+ success: false,
291
+ message: "No commits to undo"
292
+ };
293
+ }
294
+ await git.reset(["--soft", "HEAD~1"]);
295
+ return {
296
+ success: true,
297
+ message: `Undone commit: ${lastCommit.message}`,
298
+ undoneCommit: lastCommit
299
+ };
300
+ } catch (error) {
301
+ return {
302
+ success: false,
303
+ message: error instanceof Error ? error.message : String(error)
304
+ };
305
+ }
306
+ }
307
+
308
+ // src/tools/mutations.ts
309
+ import fs2 from "fs/promises";
310
+ import path3 from "path";
311
+ function registerMutationTools(server2, vaultPath2, autoCommit2) {
312
+ server2.tool(
313
+ "vault_add_to_section",
314
+ "Add content to a specific section in a markdown note",
315
+ {
316
+ path: z.string().describe('Vault-relative path to the note (e.g., "daily-notes/2026-01-28.md")'),
317
+ section: z.string().describe('Heading text to add to (e.g., "Log" or "## Log")'),
318
+ content: z.string().describe("Content to add to the section"),
319
+ position: z.enum(["append", "prepend"]).default("append").describe("Where to insert content"),
320
+ format: z.enum(["plain", "bullet", "task", "numbered", "timestamp-bullet"]).default("plain").describe("How to format the content")
321
+ },
322
+ async ({ path: notePath, section, content, position, format }) => {
323
+ try {
324
+ const fullPath = path3.join(vaultPath2, notePath);
325
+ try {
326
+ await fs2.access(fullPath);
327
+ } catch {
328
+ const result2 = {
329
+ success: false,
330
+ message: `File not found: ${notePath}`,
331
+ path: notePath
332
+ };
333
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
334
+ }
335
+ const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
336
+ const sectionBoundary = findSection(fileContent, section);
337
+ if (!sectionBoundary) {
338
+ const result2 = {
339
+ success: false,
340
+ message: `Section not found: ${section}`,
341
+ path: notePath
342
+ };
343
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
344
+ }
345
+ const formattedContent = formatContent(content, format);
346
+ const updatedContent = insertInSection(
347
+ fileContent,
348
+ sectionBoundary,
349
+ formattedContent,
350
+ position
351
+ );
352
+ await writeVaultFile(vaultPath2, notePath, updatedContent, frontmatter);
353
+ let gitCommit;
354
+ let gitError;
355
+ if (autoCommit2) {
356
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:Add]");
357
+ if (gitResult.success) {
358
+ gitCommit = gitResult.hash;
359
+ } else {
360
+ gitError = gitResult.error;
361
+ }
362
+ }
363
+ const preview = formattedContent;
364
+ const result = {
365
+ success: true,
366
+ message: `Added content to section "${sectionBoundary.name}" in ${notePath}`,
367
+ path: notePath,
368
+ preview,
369
+ gitCommit,
370
+ gitError
371
+ };
372
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
373
+ } catch (error) {
374
+ const result = {
375
+ success: false,
376
+ message: `Failed to add content: ${error instanceof Error ? error.message : String(error)}`,
377
+ path: notePath
378
+ };
379
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
380
+ }
381
+ }
382
+ );
383
+ server2.tool(
384
+ "vault_remove_from_section",
385
+ "Remove content from a specific section in a markdown note",
386
+ {
387
+ path: z.string().describe("Vault-relative path to the note"),
388
+ section: z.string().describe('Heading text to remove from (e.g., "Log" or "## Log")'),
389
+ pattern: z.string().describe("Text or pattern to match for removal"),
390
+ mode: z.enum(["first", "last", "all"]).default("first").describe("Which matches to remove"),
391
+ useRegex: z.boolean().default(false).describe("Treat pattern as regex")
392
+ },
393
+ async ({ path: notePath, section, pattern, mode, useRegex }) => {
394
+ try {
395
+ const fullPath = path3.join(vaultPath2, notePath);
396
+ try {
397
+ await fs2.access(fullPath);
398
+ } catch {
399
+ const result2 = {
400
+ success: false,
401
+ message: `File not found: ${notePath}`,
402
+ path: notePath
403
+ };
404
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
405
+ }
406
+ const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
407
+ const sectionBoundary = findSection(fileContent, section);
408
+ if (!sectionBoundary) {
409
+ const result2 = {
410
+ success: false,
411
+ message: `Section not found: ${section}`,
412
+ path: notePath
413
+ };
414
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
415
+ }
416
+ const removeResult = removeFromSection(
417
+ fileContent,
418
+ sectionBoundary,
419
+ pattern,
420
+ mode,
421
+ useRegex
422
+ );
423
+ if (removeResult.removedCount === 0) {
424
+ const result2 = {
425
+ success: false,
426
+ message: `No content matching "${pattern}" found in section "${sectionBoundary.name}"`,
427
+ path: notePath
428
+ };
429
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
430
+ }
431
+ await writeVaultFile(vaultPath2, notePath, removeResult.content, frontmatter);
432
+ let gitCommit;
433
+ let gitError;
434
+ if (autoCommit2) {
435
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:Remove]");
436
+ if (gitResult.success) {
437
+ gitCommit = gitResult.hash;
438
+ } else {
439
+ gitError = gitResult.error;
440
+ }
441
+ }
442
+ const result = {
443
+ success: true,
444
+ message: `Removed ${removeResult.removedCount} line(s) from section "${sectionBoundary.name}" in ${notePath}`,
445
+ path: notePath,
446
+ preview: removeResult.removedLines.join("\n"),
447
+ gitCommit,
448
+ gitError
449
+ };
450
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
451
+ } catch (error) {
452
+ const result = {
453
+ success: false,
454
+ message: `Failed to remove content: ${error instanceof Error ? error.message : String(error)}`,
455
+ path: notePath
456
+ };
457
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
458
+ }
459
+ }
460
+ );
461
+ server2.tool(
462
+ "vault_replace_in_section",
463
+ "Replace content in a specific section in a markdown note",
464
+ {
465
+ path: z.string().describe("Vault-relative path to the note"),
466
+ section: z.string().describe('Heading text to search in (e.g., "Log" or "## Log")'),
467
+ search: z.string().describe("Text or pattern to search for"),
468
+ replacement: z.string().describe("Text to replace with"),
469
+ mode: z.enum(["first", "last", "all"]).default("first").describe("Which matches to replace"),
470
+ useRegex: z.boolean().default(false).describe("Treat search as regex")
471
+ },
472
+ async ({ path: notePath, section, search, replacement, mode, useRegex }) => {
473
+ try {
474
+ const fullPath = path3.join(vaultPath2, notePath);
475
+ try {
476
+ await fs2.access(fullPath);
477
+ } catch {
478
+ const result2 = {
479
+ success: false,
480
+ message: `File not found: ${notePath}`,
481
+ path: notePath
482
+ };
483
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
484
+ }
485
+ const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
486
+ const sectionBoundary = findSection(fileContent, section);
487
+ if (!sectionBoundary) {
488
+ const result2 = {
489
+ success: false,
490
+ message: `Section not found: ${section}`,
491
+ path: notePath
492
+ };
493
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
494
+ }
495
+ const replaceResult = replaceInSection(
496
+ fileContent,
497
+ sectionBoundary,
498
+ search,
499
+ replacement,
500
+ mode,
501
+ useRegex
502
+ );
503
+ if (replaceResult.replacedCount === 0) {
504
+ const result2 = {
505
+ success: false,
506
+ message: `No content matching "${search}" found in section "${sectionBoundary.name}"`,
507
+ path: notePath
508
+ };
509
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
510
+ }
511
+ await writeVaultFile(vaultPath2, notePath, replaceResult.content, frontmatter);
512
+ let gitCommit;
513
+ let gitError;
514
+ if (autoCommit2) {
515
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:Replace]");
516
+ if (gitResult.success) {
517
+ gitCommit = gitResult.hash;
518
+ } else {
519
+ gitError = gitResult.error;
520
+ }
521
+ }
522
+ const previewLines = replaceResult.originalLines.map(
523
+ (orig, i) => `- ${orig}
524
+ + ${replaceResult.newLines[i]}`
525
+ );
526
+ const result = {
527
+ success: true,
528
+ message: `Replaced ${replaceResult.replacedCount} occurrence(s) in section "${sectionBoundary.name}" in ${notePath}`,
529
+ path: notePath,
530
+ preview: previewLines.join("\n"),
531
+ gitCommit,
532
+ gitError
533
+ };
534
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
535
+ } catch (error) {
536
+ const result = {
537
+ success: false,
538
+ message: `Failed to replace content: ${error instanceof Error ? error.message : String(error)}`,
539
+ path: notePath
540
+ };
541
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
542
+ }
543
+ }
544
+ );
545
+ console.error("[Crank] Mutation tools registered (vault_add_to_section, vault_remove_from_section, vault_replace_in_section)");
546
+ }
547
+
548
+ // src/tools/tasks.ts
549
+ import { z as z2 } from "zod";
550
+ import fs3 from "fs/promises";
551
+ import path4 from "path";
552
+ var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
553
+ function findTasks(content, section) {
554
+ const lines = content.split("\n");
555
+ const tasks = [];
556
+ const startLine = section?.contentStartLine ?? 0;
557
+ const endLine = section?.endLine ?? lines.length - 1;
558
+ for (let i = startLine; i <= endLine; i++) {
559
+ const line = lines[i];
560
+ const match = line.match(TASK_REGEX);
561
+ if (match) {
562
+ tasks.push({
563
+ line: i,
564
+ text: match[3].trim(),
565
+ completed: match[2].toLowerCase() === "x",
566
+ indent: match[1],
567
+ rawLine: line
568
+ });
569
+ }
570
+ }
571
+ return tasks;
572
+ }
573
+ function toggleTask(content, lineNumber) {
574
+ const lines = content.split("\n");
575
+ if (lineNumber < 0 || lineNumber >= lines.length) {
576
+ return null;
577
+ }
578
+ const line = lines[lineNumber];
579
+ const match = line.match(TASK_REGEX);
580
+ if (!match) {
581
+ return null;
582
+ }
583
+ const wasCompleted = match[2].toLowerCase() === "x";
584
+ const newState = !wasCompleted;
585
+ if (wasCompleted) {
586
+ lines[lineNumber] = line.replace(/\[[ xX]\]/, "[ ]");
587
+ } else {
588
+ lines[lineNumber] = line.replace(/\[[ xX]\]/, "[x]");
589
+ }
590
+ return {
591
+ content: lines.join("\n"),
592
+ newState
593
+ };
594
+ }
595
+ function registerTaskTools(server2, vaultPath2, autoCommit2) {
596
+ server2.tool(
597
+ "vault_toggle_task",
598
+ "Toggle a task checkbox between checked and unchecked",
599
+ {
600
+ path: z2.string().describe("Vault-relative path to the note"),
601
+ task: z2.string().describe("Task text to find (partial match supported)"),
602
+ section: z2.string().optional().describe("Optional: limit search to this section")
603
+ },
604
+ async ({ path: notePath, task, section }) => {
605
+ try {
606
+ const fullPath = path4.join(vaultPath2, notePath);
607
+ try {
608
+ await fs3.access(fullPath);
609
+ } catch {
610
+ const result2 = {
611
+ success: false,
612
+ message: `File not found: ${notePath}`,
613
+ path: notePath
614
+ };
615
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
616
+ }
617
+ const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
618
+ let sectionBoundary;
619
+ if (section) {
620
+ const found = findSection(fileContent, section);
621
+ if (!found) {
622
+ const result2 = {
623
+ success: false,
624
+ message: `Section not found: ${section}`,
625
+ path: notePath
626
+ };
627
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
628
+ }
629
+ sectionBoundary = found;
630
+ }
631
+ const tasks = findTasks(fileContent, sectionBoundary);
632
+ const searchLower = task.toLowerCase();
633
+ const matchingTask = tasks.find(
634
+ (t) => t.text.toLowerCase().includes(searchLower)
635
+ );
636
+ if (!matchingTask) {
637
+ const result2 = {
638
+ success: false,
639
+ message: `No task found matching "${task}"${section ? ` in section "${section}"` : ""}`,
640
+ path: notePath
641
+ };
642
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
643
+ }
644
+ const toggleResult = toggleTask(fileContent, matchingTask.line);
645
+ if (!toggleResult) {
646
+ const result2 = {
647
+ success: false,
648
+ message: "Failed to toggle task",
649
+ path: notePath
650
+ };
651
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
652
+ }
653
+ await writeVaultFile(vaultPath2, notePath, toggleResult.content, frontmatter);
654
+ let gitCommit;
655
+ let gitError;
656
+ if (autoCommit2) {
657
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:Task]");
658
+ if (gitResult.success) {
659
+ gitCommit = gitResult.hash;
660
+ } else {
661
+ gitError = gitResult.error;
662
+ }
663
+ }
664
+ const newStatus = toggleResult.newState ? "completed" : "incomplete";
665
+ const checkbox = toggleResult.newState ? "[x]" : "[ ]";
666
+ const result = {
667
+ success: true,
668
+ message: `Toggled task to ${newStatus} in ${notePath}`,
669
+ path: notePath,
670
+ preview: `${checkbox} ${matchingTask.text}`,
671
+ gitCommit,
672
+ gitError
673
+ };
674
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
675
+ } catch (error) {
676
+ const result = {
677
+ success: false,
678
+ message: `Failed to toggle task: ${error instanceof Error ? error.message : String(error)}`,
679
+ path: notePath
680
+ };
681
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
682
+ }
683
+ }
684
+ );
685
+ server2.tool(
686
+ "vault_add_task",
687
+ "Add a new task to a section in a markdown note",
688
+ {
689
+ path: z2.string().describe("Vault-relative path to the note"),
690
+ section: z2.string().describe("Section to add the task to"),
691
+ task: z2.string().describe("Task text (without checkbox)"),
692
+ position: z2.enum(["append", "prepend"]).default("append").describe("Where to add the task"),
693
+ completed: z2.boolean().default(false).describe("Whether the task should start as completed")
694
+ },
695
+ async ({ path: notePath, section, task, position, completed }) => {
696
+ try {
697
+ const fullPath = path4.join(vaultPath2, notePath);
698
+ try {
699
+ await fs3.access(fullPath);
700
+ } catch {
701
+ const result2 = {
702
+ success: false,
703
+ message: `File not found: ${notePath}`,
704
+ path: notePath
705
+ };
706
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
707
+ }
708
+ const { content: fileContent, frontmatter } = await readVaultFile(vaultPath2, notePath);
709
+ const sectionBoundary = findSection(fileContent, section);
710
+ if (!sectionBoundary) {
711
+ const result2 = {
712
+ success: false,
713
+ message: `Section not found: ${section}`,
714
+ path: notePath
715
+ };
716
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
717
+ }
718
+ const checkbox = completed ? "[x]" : "[ ]";
719
+ const taskLine = `- ${checkbox} ${task.trim()}`;
720
+ const updatedContent = insertInSection(
721
+ fileContent,
722
+ sectionBoundary,
723
+ taskLine,
724
+ position
725
+ );
726
+ await writeVaultFile(vaultPath2, notePath, updatedContent, frontmatter);
727
+ let gitCommit;
728
+ let gitError;
729
+ if (autoCommit2) {
730
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:Task]");
731
+ if (gitResult.success) {
732
+ gitCommit = gitResult.hash;
733
+ } else {
734
+ gitError = gitResult.error;
735
+ }
736
+ }
737
+ const result = {
738
+ success: true,
739
+ message: `Added task to section "${sectionBoundary.name}" in ${notePath}`,
740
+ path: notePath,
741
+ preview: taskLine,
742
+ gitCommit,
743
+ gitError
744
+ };
745
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
746
+ } catch (error) {
747
+ const result = {
748
+ success: false,
749
+ message: `Failed to add task: ${error instanceof Error ? error.message : String(error)}`,
750
+ path: notePath
751
+ };
752
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
753
+ }
754
+ }
755
+ );
756
+ console.error("[Crank] Task tools registered (vault_toggle_task, vault_add_task)");
757
+ }
758
+
759
+ // src/tools/frontmatter.ts
760
+ import { z as z3 } from "zod";
761
+ import fs4 from "fs/promises";
762
+ import path5 from "path";
763
+ function registerFrontmatterTools(server2, vaultPath2, autoCommit2) {
764
+ server2.tool(
765
+ "vault_update_frontmatter",
766
+ "Update frontmatter fields in a note (merge with existing frontmatter)",
767
+ {
768
+ path: z3.string().describe("Vault-relative path to the note"),
769
+ frontmatter: z3.record(z3.any()).describe("Frontmatter fields to update (JSON object)")
770
+ },
771
+ async ({ path: notePath, frontmatter: updates }) => {
772
+ try {
773
+ const fullPath = path5.join(vaultPath2, notePath);
774
+ try {
775
+ await fs4.access(fullPath);
776
+ } catch {
777
+ const result2 = {
778
+ success: false,
779
+ message: `File not found: ${notePath}`,
780
+ path: notePath
781
+ };
782
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
783
+ }
784
+ const { content, frontmatter } = await readVaultFile(vaultPath2, notePath);
785
+ const updatedFrontmatter = { ...frontmatter, ...updates };
786
+ await writeVaultFile(vaultPath2, notePath, content, updatedFrontmatter);
787
+ let gitCommit;
788
+ let gitError;
789
+ if (autoCommit2) {
790
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:FM]");
791
+ if (gitResult.success) {
792
+ gitCommit = gitResult.hash;
793
+ } else {
794
+ gitError = gitResult.error;
795
+ }
796
+ }
797
+ const updatedKeys = Object.keys(updates);
798
+ const preview = updatedKeys.map((k) => `${k}: ${JSON.stringify(updates[k])}`).join("\n");
799
+ const result = {
800
+ success: true,
801
+ message: `Updated ${updatedKeys.length} frontmatter field(s) in ${notePath}`,
802
+ path: notePath,
803
+ preview,
804
+ gitCommit,
805
+ gitError
806
+ };
807
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
808
+ } catch (error) {
809
+ const result = {
810
+ success: false,
811
+ message: `Failed to update frontmatter: ${error instanceof Error ? error.message : String(error)}`,
812
+ path: notePath
813
+ };
814
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
815
+ }
816
+ }
817
+ );
818
+ server2.tool(
819
+ "vault_add_frontmatter_field",
820
+ "Add a new frontmatter field to a note (only if it doesn't exist)",
821
+ {
822
+ path: z3.string().describe("Vault-relative path to the note"),
823
+ key: z3.string().describe("Field name to add"),
824
+ value: z3.any().describe("Field value (string, number, boolean, array, object)")
825
+ },
826
+ async ({ path: notePath, key, value }) => {
827
+ try {
828
+ const fullPath = path5.join(vaultPath2, notePath);
829
+ try {
830
+ await fs4.access(fullPath);
831
+ } catch {
832
+ const result2 = {
833
+ success: false,
834
+ message: `File not found: ${notePath}`,
835
+ path: notePath
836
+ };
837
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
838
+ }
839
+ const { content, frontmatter } = await readVaultFile(vaultPath2, notePath);
840
+ if (key in frontmatter) {
841
+ const result2 = {
842
+ success: false,
843
+ message: `Field "${key}" already exists. Use vault_update_frontmatter to modify existing fields.`,
844
+ path: notePath
845
+ };
846
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
847
+ }
848
+ const updatedFrontmatter = { ...frontmatter, [key]: value };
849
+ await writeVaultFile(vaultPath2, notePath, content, updatedFrontmatter);
850
+ let gitCommit;
851
+ let gitError;
852
+ if (autoCommit2) {
853
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:FM]");
854
+ if (gitResult.success) {
855
+ gitCommit = gitResult.hash;
856
+ } else {
857
+ gitError = gitResult.error;
858
+ }
859
+ }
860
+ const result = {
861
+ success: true,
862
+ message: `Added frontmatter field "${key}" to ${notePath}`,
863
+ path: notePath,
864
+ preview: `${key}: ${JSON.stringify(value)}`,
865
+ gitCommit,
866
+ gitError
867
+ };
868
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
869
+ } catch (error) {
870
+ const result = {
871
+ success: false,
872
+ message: `Failed to add frontmatter field: ${error instanceof Error ? error.message : String(error)}`,
873
+ path: notePath
874
+ };
875
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
876
+ }
877
+ }
878
+ );
879
+ console.error("[Crank] Frontmatter tools registered (vault_update_frontmatter, vault_add_frontmatter_field)");
880
+ }
881
+
882
+ // src/tools/notes.ts
883
+ import { z as z4 } from "zod";
884
+ import fs5 from "fs/promises";
885
+ import path6 from "path";
886
+ function registerNoteTools(server2, vaultPath2, autoCommit2) {
887
+ server2.tool(
888
+ "vault_create_note",
889
+ "Create a new note in the vault with optional frontmatter and content",
890
+ {
891
+ path: z4.string().describe('Vault-relative path for the new note (e.g., "daily-notes/2026-01-28.md")'),
892
+ content: z4.string().default("").describe("Initial content for the note"),
893
+ frontmatter: z4.record(z4.any()).default({}).describe("Frontmatter fields (JSON object)"),
894
+ overwrite: z4.boolean().default(false).describe("If true, overwrite existing file")
895
+ },
896
+ async ({ path: notePath, content, frontmatter, overwrite }) => {
897
+ try {
898
+ if (!validatePath(vaultPath2, notePath)) {
899
+ const result2 = {
900
+ success: false,
901
+ message: "Invalid path: path traversal not allowed",
902
+ path: notePath
903
+ };
904
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
905
+ }
906
+ const fullPath = path6.join(vaultPath2, notePath);
907
+ try {
908
+ await fs5.access(fullPath);
909
+ if (!overwrite) {
910
+ const result2 = {
911
+ success: false,
912
+ message: `File already exists: ${notePath}. Use overwrite=true to replace.`,
913
+ path: notePath
914
+ };
915
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
916
+ }
917
+ } catch {
918
+ }
919
+ const dir = path6.dirname(fullPath);
920
+ await fs5.mkdir(dir, { recursive: true });
921
+ await writeVaultFile(vaultPath2, notePath, content, frontmatter);
922
+ let gitCommit;
923
+ let gitError;
924
+ if (autoCommit2) {
925
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:Create]");
926
+ if (gitResult.success) {
927
+ gitCommit = gitResult.hash;
928
+ } else {
929
+ gitError = gitResult.error;
930
+ }
931
+ }
932
+ const result = {
933
+ success: true,
934
+ message: `Created note: ${notePath}`,
935
+ path: notePath,
936
+ preview: `Frontmatter fields: ${Object.keys(frontmatter).join(", ") || "none"}
937
+ Content length: ${content.length} chars`,
938
+ gitCommit,
939
+ gitError
940
+ };
941
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
942
+ } catch (error) {
943
+ const result = {
944
+ success: false,
945
+ message: `Failed to create note: ${error instanceof Error ? error.message : String(error)}`,
946
+ path: notePath
947
+ };
948
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
949
+ }
950
+ }
951
+ );
952
+ server2.tool(
953
+ "vault_delete_note",
954
+ "Delete a note from the vault",
955
+ {
956
+ path: z4.string().describe("Vault-relative path to the note to delete"),
957
+ confirm: z4.boolean().default(false).describe("Must be true to confirm deletion")
958
+ },
959
+ async ({ path: notePath, confirm }) => {
960
+ try {
961
+ if (!confirm) {
962
+ const result2 = {
963
+ success: false,
964
+ message: "Deletion requires explicit confirmation (confirm=true)",
965
+ path: notePath
966
+ };
967
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
968
+ }
969
+ if (!validatePath(vaultPath2, notePath)) {
970
+ const result2 = {
971
+ success: false,
972
+ message: "Invalid path: path traversal not allowed",
973
+ path: notePath
974
+ };
975
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
976
+ }
977
+ const fullPath = path6.join(vaultPath2, notePath);
978
+ try {
979
+ await fs5.access(fullPath);
980
+ } catch {
981
+ const result2 = {
982
+ success: false,
983
+ message: `File not found: ${notePath}`,
984
+ path: notePath
985
+ };
986
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
987
+ }
988
+ await fs5.unlink(fullPath);
989
+ let gitCommit;
990
+ let gitError;
991
+ if (autoCommit2) {
992
+ const gitResult = await commitChange(vaultPath2, notePath, "[Crank:Delete]");
993
+ if (gitResult.success) {
994
+ gitCommit = gitResult.hash;
995
+ } else {
996
+ gitError = gitResult.error;
997
+ }
998
+ }
999
+ const result = {
1000
+ success: true,
1001
+ message: `Deleted note: ${notePath}`,
1002
+ path: notePath,
1003
+ gitCommit,
1004
+ gitError
1005
+ };
1006
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1007
+ } catch (error) {
1008
+ const result = {
1009
+ success: false,
1010
+ message: `Failed to delete note: ${error instanceof Error ? error.message : String(error)}`,
1011
+ path: notePath
1012
+ };
1013
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1014
+ }
1015
+ }
1016
+ );
1017
+ console.error("[Crank] Note tools registered (vault_create_note, vault_delete_note)");
1018
+ }
1019
+
1020
+ // src/tools/system.ts
1021
+ import { z as z5 } from "zod";
1022
+ import fs6 from "fs/promises";
1023
+ import path7 from "path";
1024
+ function registerSystemTools(server2, vaultPath2, autoCommit2) {
1025
+ server2.tool(
1026
+ "vault_list_sections",
1027
+ "List all sections (headings) in a markdown note",
1028
+ {
1029
+ path: z5.string().describe("Vault-relative path to the note"),
1030
+ minLevel: z5.number().min(1).max(6).default(1).describe("Minimum heading level to include"),
1031
+ maxLevel: z5.number().min(1).max(6).default(6).describe("Maximum heading level to include")
1032
+ },
1033
+ async ({ path: notePath, minLevel, maxLevel }) => {
1034
+ try {
1035
+ if (!validatePath(vaultPath2, notePath)) {
1036
+ const result2 = {
1037
+ success: false,
1038
+ message: "Invalid path: path traversal not allowed",
1039
+ path: notePath
1040
+ };
1041
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1042
+ }
1043
+ const fullPath = path7.join(vaultPath2, notePath);
1044
+ try {
1045
+ await fs6.access(fullPath);
1046
+ } catch {
1047
+ const result2 = {
1048
+ success: false,
1049
+ message: `File not found: ${notePath}`,
1050
+ path: notePath
1051
+ };
1052
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1053
+ }
1054
+ const { content: fileContent } = await readVaultFile(vaultPath2, notePath);
1055
+ const headings = extractHeadings(fileContent);
1056
+ const filteredHeadings = headings.filter(
1057
+ (h) => h.level >= minLevel && h.level <= maxLevel
1058
+ );
1059
+ const sections = filteredHeadings.map((h) => ({
1060
+ level: h.level,
1061
+ name: h.text,
1062
+ line: h.line + 1
1063
+ // 1-indexed for user display
1064
+ }));
1065
+ const result = {
1066
+ success: true,
1067
+ message: `Found ${sections.length} section(s) in ${notePath}`,
1068
+ path: notePath,
1069
+ sections
1070
+ };
1071
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1072
+ } catch (error) {
1073
+ const result = {
1074
+ success: false,
1075
+ message: `Failed to list sections: ${error instanceof Error ? error.message : String(error)}`,
1076
+ path: notePath
1077
+ };
1078
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1079
+ }
1080
+ }
1081
+ );
1082
+ server2.tool(
1083
+ "vault_undo_last_mutation",
1084
+ "Undo the last git commit (typically the last Crank mutation). Performs a soft reset.",
1085
+ {
1086
+ confirm: z5.boolean().default(false).describe("Must be true to confirm undo operation")
1087
+ },
1088
+ async ({ confirm }) => {
1089
+ try {
1090
+ if (!confirm) {
1091
+ const lastCommit = await getLastCommit(vaultPath2);
1092
+ if (!lastCommit) {
1093
+ const result3 = {
1094
+ success: false,
1095
+ message: "No commits found to undo",
1096
+ path: ""
1097
+ };
1098
+ return { content: [{ type: "text", text: JSON.stringify(result3, null, 2) }] };
1099
+ }
1100
+ const result2 = {
1101
+ success: false,
1102
+ message: `Undo requires confirmation (confirm=true). Would undo: "${lastCommit.message}"`,
1103
+ path: "",
1104
+ preview: `Commit: ${lastCommit.hash.substring(0, 7)}
1105
+ Message: ${lastCommit.message}
1106
+ Date: ${lastCommit.date}`
1107
+ };
1108
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1109
+ }
1110
+ if (!autoCommit2) {
1111
+ const result2 = {
1112
+ success: false,
1113
+ message: 'Undo is only available when AUTO_COMMIT is enabled. Set AUTO_COMMIT="true" in .mcp.json to enable automatic git commits for each mutation.',
1114
+ path: ""
1115
+ };
1116
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1117
+ }
1118
+ const isRepo = await isGitRepo(vaultPath2);
1119
+ if (!isRepo) {
1120
+ const result2 = {
1121
+ success: false,
1122
+ message: "Vault is not a git repository. Undo is only available for git-tracked vaults.",
1123
+ path: ""
1124
+ };
1125
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1126
+ }
1127
+ const undoResult = await undoLastCommit(vaultPath2);
1128
+ if (!undoResult.success) {
1129
+ const result2 = {
1130
+ success: false,
1131
+ message: undoResult.message,
1132
+ path: ""
1133
+ };
1134
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
1135
+ }
1136
+ const result = {
1137
+ success: true,
1138
+ message: undoResult.message,
1139
+ path: "",
1140
+ preview: undoResult.undoneCommit ? `Commit: ${undoResult.undoneCommit.hash.substring(0, 7)}
1141
+ Message: ${undoResult.undoneCommit.message}` : void 0
1142
+ };
1143
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1144
+ } catch (error) {
1145
+ const result = {
1146
+ success: false,
1147
+ message: `Failed to undo: ${error instanceof Error ? error.message : String(error)}`,
1148
+ path: ""
1149
+ };
1150
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1151
+ }
1152
+ }
1153
+ );
1154
+ console.error("[Crank] System tools registered (vault_list_sections, vault_undo_last_mutation)");
1155
+ }
1156
+
1157
+ // src/core/vaultRoot.ts
1158
+ import * as fs7 from "fs";
1159
+ import * as path8 from "path";
1160
+ var VAULT_MARKERS = [".obsidian", ".claude"];
1161
+ function findVaultRoot(startPath) {
1162
+ let current = path8.resolve(startPath || process.cwd());
1163
+ while (true) {
1164
+ for (const marker of VAULT_MARKERS) {
1165
+ const markerPath = path8.join(current, marker);
1166
+ if (fs7.existsSync(markerPath) && fs7.statSync(markerPath).isDirectory()) {
1167
+ return current;
1168
+ }
1169
+ }
1170
+ const parent = path8.dirname(current);
1171
+ if (parent === current) {
1172
+ return startPath || process.cwd();
1173
+ }
1174
+ current = parent;
1175
+ }
1176
+ }
1177
+
1178
+ // src/index.ts
1179
+ var server = new McpServer({
1180
+ name: "flywheel-crank",
1181
+ version: "0.1.0"
1182
+ });
1183
+ var vaultPath = process.env.PROJECT_PATH || findVaultRoot();
1184
+ var autoCommit = process.env.AUTO_COMMIT === "true";
1185
+ console.error(`[Crank] Starting Flywheel Crank MCP server`);
1186
+ console.error(`[Crank] Vault path: ${vaultPath}`);
1187
+ console.error(`[Crank] Auto-commit: ${autoCommit}`);
1188
+ registerMutationTools(server, vaultPath, autoCommit);
1189
+ registerTaskTools(server, vaultPath, autoCommit);
1190
+ registerFrontmatterTools(server, vaultPath, autoCommit);
1191
+ registerNoteTools(server, vaultPath, autoCommit);
1192
+ registerSystemTools(server, vaultPath, autoCommit);
1193
+ var transport = new StdioServerTransport();
1194
+ await server.connect(transport);
1195
+ console.error(`[Crank] Flywheel Crank MCP server started successfully`);