@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.
- package/LICENSE +661 -0
- package/README.md +154 -0
- package/dist/index.js +1195 -0
- 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`);
|