@gmickel/gno 0.34.1 → 0.35.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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared section extraction and anchor helpers.
3
+ *
4
+ * Browser-safe.
5
+ *
6
+ * @module src/core/sections
7
+ */
8
+
9
+ export interface DocumentSection {
10
+ anchor: string;
11
+ level: number;
12
+ line: number;
13
+ title: string;
14
+ }
15
+
16
+ const HEADING_REGEX = /^(#{1,6})\s+(.+?)\s*#*\s*$/u;
17
+
18
+ export function slugifySectionTitle(title: string): string {
19
+ return (
20
+ title
21
+ .normalize("NFC")
22
+ .toLowerCase()
23
+ .trim()
24
+ .replaceAll(/[^\p{L}\p{N}\s-]/gu, "")
25
+ .replaceAll(/\s+/g, "-")
26
+ .replaceAll(/-+/g, "-")
27
+ .replace(/^-|-$/g, "") || "section"
28
+ );
29
+ }
30
+
31
+ export function extractSections(content: string): DocumentSection[] {
32
+ const sections: DocumentSection[] = [];
33
+ const counts = new Map<string, number>();
34
+ const lines = content.split("\n");
35
+
36
+ for (const [index, line] of lines.entries()) {
37
+ const match = HEADING_REGEX.exec(line);
38
+ if (!match) {
39
+ continue;
40
+ }
41
+
42
+ const level = match[1]?.length ?? 0;
43
+ const title = match[2]?.trim() ?? "";
44
+ if (!title) {
45
+ continue;
46
+ }
47
+
48
+ const baseAnchor = slugifySectionTitle(title);
49
+ const count = (counts.get(baseAnchor) ?? 0) + 1;
50
+ counts.set(baseAnchor, count);
51
+ const anchor = count === 1 ? baseAnchor : `${baseAnchor}-${count}`;
52
+
53
+ sections.push({
54
+ anchor,
55
+ level,
56
+ line: index + 1,
57
+ title,
58
+ });
59
+ }
60
+
61
+ return sections;
62
+ }
@@ -9,7 +9,7 @@ import { realpath } from "node:fs/promises";
9
9
  // node:os for homedir (no Bun os utils)
10
10
  import { homedir } from "node:os";
11
11
  // node:path for path utils (no Bun path utils)
12
- import { isAbsolute, join, normalize, sep } from "node:path";
12
+ import { isAbsolute, join, posix as pathPosix } from "node:path";
13
13
 
14
14
  import { toAbsolutePath } from "../config/paths";
15
15
 
@@ -48,8 +48,9 @@ export function validateRelPath(relPath: string): string {
48
48
  throw new Error("relPath contains invalid characters");
49
49
  }
50
50
 
51
- const normalized = normalize(relPath);
52
- const segments = normalized.split(sep);
51
+ const normalizedInput = relPath.replaceAll("\\", "/");
52
+ const normalized = pathPosix.normalize(normalizedInput);
53
+ const segments = normalized.split("/");
53
54
  if (segments.includes("..")) {
54
55
  throw new Error("relPath cannot escape collection root");
55
56
  }
@@ -15,6 +15,15 @@ import { buildUri } from "../../app/constants";
15
15
  import { MCP_ERRORS } from "../../core/errors";
16
16
  import { withWriteLock } from "../../core/file-lock";
17
17
  import { atomicWrite } from "../../core/file-ops";
18
+ import {
19
+ resolveNoteCreatePlan,
20
+ type NoteCollisionPolicy,
21
+ } from "../../core/note-creation";
22
+ import {
23
+ getNotePreset,
24
+ resolveNotePreset,
25
+ type NotePresetId,
26
+ } from "../../core/note-presets";
18
27
  import { normalizeTag, validateTag } from "../../core/tags";
19
28
  import {
20
29
  normalizeCollectionName,
@@ -27,10 +36,13 @@ import { runTool, type ToolResult } from "./index";
27
36
 
28
37
  interface CaptureInput {
29
38
  collection: string;
30
- content: string;
39
+ content?: string;
31
40
  title?: string;
32
41
  path?: string;
33
42
  overwrite?: boolean;
43
+ folderPath?: string;
44
+ collisionPolicy?: NoteCollisionPolicy;
45
+ presetId?: NotePresetId;
34
46
  tags?: string[];
35
47
  }
36
48
 
@@ -123,17 +135,46 @@ export function handleCapture(
123
135
  );
124
136
  }
125
137
 
138
+ if (args.presetId && !getNotePreset(args.presetId)) {
139
+ throw new Error(
140
+ `${MCP_ERRORS.INVALID_INPUT.code}: Unknown presetId: ${args.presetId}`
141
+ );
142
+ }
143
+
144
+ const existingDocs = await ctx.store.listDocuments(collectionName);
145
+ if (!existingDocs.ok) {
146
+ throw new Error(existingDocs.error.message);
147
+ }
148
+
126
149
  let relPath: string;
127
- if (args.path) {
128
- try {
129
- relPath = ensureMarkdownExtension(validateRelPath(args.path));
130
- } catch (error) {
131
- const message =
132
- error instanceof Error ? error.message : String(error);
133
- throw new Error(`${MCP_ERRORS.INVALID_PATH.code}: ${message}`);
150
+ try {
151
+ const plan = resolveNoteCreatePlan(
152
+ {
153
+ collection: collection.name,
154
+ relPath: args.path
155
+ ? ensureMarkdownExtension(validateRelPath(args.path))
156
+ : undefined,
157
+ title:
158
+ args.title?.trim() ||
159
+ generateFilename(args.title, args.content ?? "").replace(
160
+ /\.md$/u,
161
+ ""
162
+ ),
163
+ folderPath: args.folderPath,
164
+ collisionPolicy: args.collisionPolicy,
165
+ },
166
+ existingDocs.value.map((doc) => doc.relPath)
167
+ );
168
+ if (plan.openedExisting) {
169
+ throw new Error(
170
+ `${MCP_ERRORS.CONFLICT.code}: Existing note resolution is not supported through gno_capture yet`
171
+ );
134
172
  }
135
- } else {
136
- relPath = generateFilename(args.title, args.content);
173
+ relPath = plan.relPath;
174
+ } catch (error) {
175
+ const message =
176
+ error instanceof Error ? error.message : String(error);
177
+ throw new Error(`${MCP_ERRORS.INVALID_PATH.code}: ${message}`);
137
178
  }
138
179
 
139
180
  assertNotSensitive(relPath);
@@ -164,12 +205,30 @@ export function handleCapture(
164
205
  }
165
206
 
166
207
  // Update frontmatter with tags for Markdown files
167
- let contentToWrite = args.content;
208
+ const resolvedPreset = resolveNotePreset({
209
+ presetId: args.presetId,
210
+ title:
211
+ args.title?.trim() ||
212
+ relPath
213
+ .split("/")
214
+ .pop()
215
+ ?.replace(/\.[^.]+$/u, "") ||
216
+ "Untitled",
217
+ tags: normalizedTags,
218
+ body: args.content,
219
+ });
220
+ let contentToWrite =
221
+ resolvedPreset?.content ??
222
+ args.content ??
223
+ `# ${args.title?.trim() || "Untitled"}\n`;
168
224
  const isMarkdown =
169
225
  relPath.endsWith(".md") || relPath.endsWith(".markdown");
170
226
  if (isMarkdown && normalizedTags.length > 0) {
171
227
  // Use shared utility that handles all frontmatter edge cases
172
- contentToWrite = updateFrontmatterTags(args.content, normalizedTags);
228
+ contentToWrite = updateFrontmatterTags(
229
+ contentToWrite,
230
+ normalizedTags
231
+ );
173
232
  }
174
233
 
175
234
  await mkdir(dirname(absPath), { recursive: true });
@@ -32,6 +32,12 @@ import { handleSearch } from "./search";
32
32
  import { handleStatus } from "./status";
33
33
  import { handleSync } from "./sync";
34
34
  import { handleVsearch } from "./vsearch";
35
+ import {
36
+ handleCreateFolder,
37
+ handleDuplicateNote,
38
+ handleMoveNote,
39
+ handleRenameNote,
40
+ } from "./workspace-write";
35
41
 
36
42
  // ─────────────────────────────────────────────────────────────────────────────
37
43
  // Shared Helpers
@@ -121,7 +127,12 @@ const captureInputSchema = z.object({
121
127
  .string()
122
128
  .min(1, "Collection cannot be empty")
123
129
  .describe("Target collection name (must already exist)"),
124
- content: z.string().describe("Document content (markdown or plain text)"),
130
+ content: z
131
+ .string()
132
+ .optional()
133
+ .describe(
134
+ "Document content (markdown or plain text). Optional when presetId provides a scaffold."
135
+ ),
125
136
  title: z
126
137
  .string()
127
138
  .optional()
@@ -132,6 +143,25 @@ const captureInputSchema = z.object({
132
143
  .describe(
133
144
  "Relative path within collection (e.g. 'notes/meeting.md'). Auto-generated from title if omitted"
134
145
  ),
146
+ folderPath: z
147
+ .string()
148
+ .optional()
149
+ .describe("Optional folder path within the collection"),
150
+ collisionPolicy: z
151
+ .enum(["error", "open_existing", "create_with_suffix"])
152
+ .optional()
153
+ .describe("How to handle name collisions"),
154
+ presetId: z
155
+ .enum([
156
+ "blank",
157
+ "project-note",
158
+ "research-note",
159
+ "decision-note",
160
+ "prompt-pattern",
161
+ "source-summary",
162
+ ])
163
+ .optional()
164
+ .describe("Optional note preset scaffold"),
135
165
  overwrite: z
136
166
  .boolean()
137
167
  .default(false)
@@ -200,6 +230,29 @@ const removeCollectionInputSchema = z.object({
200
230
  .describe("Collection name to remove"),
201
231
  });
202
232
 
233
+ const createFolderInputSchema = z.object({
234
+ collection: z.string().min(1, "Collection cannot be empty"),
235
+ name: z.string().min(1, "Folder name cannot be empty"),
236
+ parentPath: z.string().optional(),
237
+ });
238
+
239
+ const renameNoteInputSchema = z.object({
240
+ ref: z.string().min(1, "ref cannot be empty"),
241
+ name: z.string().min(1, "name cannot be empty"),
242
+ });
243
+
244
+ const moveNoteInputSchema = z.object({
245
+ ref: z.string().min(1, "ref cannot be empty"),
246
+ folderPath: z.string().min(1, "folderPath cannot be empty"),
247
+ name: z.string().optional(),
248
+ });
249
+
250
+ const duplicateNoteInputSchema = z.object({
251
+ ref: z.string().min(1, "ref cannot be empty"),
252
+ folderPath: z.string().optional(),
253
+ name: z.string().optional(),
254
+ });
255
+
203
256
  const vsearchInputSchema = z.object({
204
257
  query: z
205
258
  .string()
@@ -764,6 +817,34 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
764
817
  removeCollectionInputSchema.shape,
765
818
  (args) => handleRemoveCollection(args, ctx)
766
819
  );
820
+
821
+ server.tool(
822
+ "gno_create_folder",
823
+ "Create a folder inside an existing collection.",
824
+ createFolderInputSchema.shape,
825
+ (args) => handleCreateFolder(args, ctx)
826
+ );
827
+
828
+ server.tool(
829
+ "gno_rename_note",
830
+ "Rename an editable note in place.",
831
+ renameNoteInputSchema.shape,
832
+ (args) => handleRenameNote(args, ctx)
833
+ );
834
+
835
+ server.tool(
836
+ "gno_move_note",
837
+ "Move an editable note to another folder in the same collection.",
838
+ moveNoteInputSchema.shape,
839
+ (args) => handleMoveNote(args, ctx)
840
+ );
841
+
842
+ server.tool(
843
+ "gno_duplicate_note",
844
+ "Duplicate an editable note into the current or another folder.",
845
+ duplicateNoteInputSchema.shape,
846
+ (args) => handleDuplicateNote(args, ctx)
847
+ );
767
848
  }
768
849
 
769
850
  server.tool(
@@ -0,0 +1,321 @@
1
+ /**
2
+ * MCP workspace write tools for note/file operations.
3
+ *
4
+ * @module src/mcp/tools/workspace-write
5
+ */
6
+
7
+ // node:fs/promises for mkdir (no Bun equivalent for structure ops)
8
+ import { mkdir } from "node:fs/promises";
9
+ // node:path for dirname/join (no Bun path utils)
10
+ import { dirname, join } from "node:path";
11
+
12
+ import type { Collection } from "../../config/types";
13
+ import type { ToolContext } from "../server";
14
+
15
+ import { getDocumentCapabilities } from "../../core/document-capabilities";
16
+ import { MCP_ERRORS } from "../../core/errors";
17
+ import { withWriteLock } from "../../core/file-lock";
18
+ import {
19
+ copyFilePath,
20
+ createFolderPath,
21
+ renameFilePath,
22
+ } from "../../core/file-ops";
23
+ import {
24
+ buildRefactorWarnings,
25
+ planCreateFolder,
26
+ planDuplicateRefactor,
27
+ planMoveRefactor,
28
+ planRenameRefactor,
29
+ } from "../../core/file-refactors";
30
+ import { defaultSyncService } from "../../ingestion";
31
+ import { runTool, type ToolResult } from "./index";
32
+
33
+ interface CreateFolderInput {
34
+ collection: string;
35
+ name: string;
36
+ parentPath?: string;
37
+ }
38
+
39
+ interface RenameNoteInput {
40
+ ref: string;
41
+ name: string;
42
+ }
43
+
44
+ interface MoveNoteInput {
45
+ ref: string;
46
+ folderPath: string;
47
+ name?: string;
48
+ }
49
+
50
+ interface DuplicateNoteInput {
51
+ ref: string;
52
+ folderPath?: string;
53
+ name?: string;
54
+ }
55
+
56
+ function resolveCollection(ctx: ToolContext, name: string): Collection {
57
+ const normalized = name.trim().toLowerCase();
58
+ const collection = ctx.collections.find((entry) => entry.name === normalized);
59
+ if (!collection) {
60
+ throw new Error(
61
+ `${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${name}`
62
+ );
63
+ }
64
+ return collection;
65
+ }
66
+
67
+ async function resolveDocByRef(ctx: ToolContext, ref: string) {
68
+ const trimmed = ref.trim();
69
+ if (!trimmed) {
70
+ throw new Error(`${MCP_ERRORS.INVALID_INPUT.code}: ref cannot be empty`);
71
+ }
72
+
73
+ if (trimmed.startsWith("#")) {
74
+ const result = await ctx.store.getDocumentByDocid(trimmed);
75
+ if (!result.ok) {
76
+ throw new Error(result.error.message);
77
+ }
78
+ if (!result.value) {
79
+ throw new Error(
80
+ `${MCP_ERRORS.NOT_FOUND.code}: Document not found: ${ref}`
81
+ );
82
+ }
83
+ return result.value;
84
+ }
85
+
86
+ if (trimmed.startsWith("gno://")) {
87
+ const result = await ctx.store.getDocumentByUri(trimmed);
88
+ if (!result.ok) {
89
+ throw new Error(result.error.message);
90
+ }
91
+ if (!result.value) {
92
+ throw new Error(
93
+ `${MCP_ERRORS.NOT_FOUND.code}: Document not found: ${ref}`
94
+ );
95
+ }
96
+ return result.value;
97
+ }
98
+
99
+ const slash = trimmed.indexOf("/");
100
+ if (slash === -1) {
101
+ throw new Error(
102
+ `${MCP_ERRORS.INVALID_INPUT.code}: ref must be #docid, gno:// URI, or collection/path`
103
+ );
104
+ }
105
+
106
+ const collection = trimmed.slice(0, slash).toLowerCase();
107
+ const relPath = trimmed.slice(slash + 1);
108
+ const result = await ctx.store.getDocument(collection, relPath);
109
+ if (!result.ok) {
110
+ throw new Error(result.error.message);
111
+ }
112
+ if (!result.value) {
113
+ throw new Error(`${MCP_ERRORS.NOT_FOUND.code}: Document not found: ${ref}`);
114
+ }
115
+ return result.value;
116
+ }
117
+
118
+ async function getRefactorSnapshot(ctx: ToolContext, documentId: number) {
119
+ const [linksResult, backlinksResult] = await Promise.all([
120
+ ctx.store.getLinksForDoc(documentId),
121
+ ctx.store.getBacklinksForDoc(documentId),
122
+ ]);
123
+ if (!linksResult.ok) {
124
+ throw new Error(linksResult.error.message);
125
+ }
126
+ if (!backlinksResult.ok) {
127
+ throw new Error(backlinksResult.error.message);
128
+ }
129
+ return {
130
+ backlinks: backlinksResult.value.length,
131
+ wikiLinks: linksResult.value.filter((entry) => entry.linkType === "wiki")
132
+ .length,
133
+ markdownLinks: linksResult.value.filter(
134
+ (entry) => entry.linkType === "markdown"
135
+ ).length,
136
+ };
137
+ }
138
+
139
+ function ensureEditable(doc: {
140
+ sourceExt: string;
141
+ sourceMime: string;
142
+ mirrorHash: string | null;
143
+ }) {
144
+ const capabilities = getDocumentCapabilities({
145
+ sourceExt: doc.sourceExt,
146
+ sourceMime: doc.sourceMime,
147
+ contentAvailable: doc.mirrorHash !== null,
148
+ });
149
+ if (!capabilities.editable) {
150
+ throw new Error(
151
+ `${MCP_ERRORS.CONFLICT.code}: ${
152
+ capabilities.reason ?? "Document is read-only in place."
153
+ }`
154
+ );
155
+ }
156
+ }
157
+
158
+ export function handleCreateFolder(
159
+ args: CreateFolderInput,
160
+ ctx: ToolContext
161
+ ): Promise<ToolResult> {
162
+ return runTool(
163
+ ctx,
164
+ "gno_create_folder",
165
+ async () => {
166
+ if (!ctx.enableWrite) {
167
+ throw new Error("Write tools disabled. Start MCP with --enable-write.");
168
+ }
169
+
170
+ return withWriteLock(ctx.writeLockPath, async () => {
171
+ const collection = resolveCollection(ctx, args.collection);
172
+ const folderPath = planCreateFolder({
173
+ parentPath: args.parentPath,
174
+ name: args.name,
175
+ });
176
+ const fullPath = join(collection.path, folderPath);
177
+ await createFolderPath(fullPath);
178
+ return {
179
+ collection: collection.name,
180
+ folderPath,
181
+ path: fullPath,
182
+ };
183
+ });
184
+ },
185
+ (data) => `Created folder ${data.folderPath} in ${data.collection}`
186
+ );
187
+ }
188
+
189
+ export function handleRenameNote(
190
+ args: RenameNoteInput,
191
+ ctx: ToolContext
192
+ ): Promise<ToolResult> {
193
+ return runTool(
194
+ ctx,
195
+ "gno_rename_note",
196
+ async () => {
197
+ if (!ctx.enableWrite) {
198
+ throw new Error("Write tools disabled. Start MCP with --enable-write.");
199
+ }
200
+
201
+ return withWriteLock(ctx.writeLockPath, async () => {
202
+ const doc = await resolveDocByRef(ctx, args.ref);
203
+ ensureEditable(doc);
204
+ const collection = resolveCollection(ctx, doc.collection);
205
+ const plan = planRenameRefactor({
206
+ collection: collection.name,
207
+ currentRelPath: doc.relPath,
208
+ nextName: args.name,
209
+ });
210
+ const currentPath = join(collection.path, doc.relPath);
211
+ const nextPath = join(collection.path, plan.nextRelPath);
212
+ await renameFilePath(currentPath, nextPath);
213
+ await defaultSyncService.syncCollection(collection, ctx.store, {
214
+ runUpdateCmd: false,
215
+ });
216
+ return {
217
+ uri: plan.nextUri,
218
+ relPath: plan.nextRelPath,
219
+ warnings: buildRefactorWarnings(
220
+ await getRefactorSnapshot(ctx, doc.id),
221
+ { filenameChanged: true }
222
+ ).warnings,
223
+ };
224
+ });
225
+ },
226
+ (data) => `Renamed note to ${data.relPath}`
227
+ );
228
+ }
229
+
230
+ export function handleMoveNote(
231
+ args: MoveNoteInput,
232
+ ctx: ToolContext
233
+ ): Promise<ToolResult> {
234
+ return runTool(
235
+ ctx,
236
+ "gno_move_note",
237
+ async () => {
238
+ if (!ctx.enableWrite) {
239
+ throw new Error("Write tools disabled. Start MCP with --enable-write.");
240
+ }
241
+
242
+ return withWriteLock(ctx.writeLockPath, async () => {
243
+ const doc = await resolveDocByRef(ctx, args.ref);
244
+ ensureEditable(doc);
245
+ const collection = resolveCollection(ctx, doc.collection);
246
+ const plan = planMoveRefactor({
247
+ collection: collection.name,
248
+ currentRelPath: doc.relPath,
249
+ folderPath: args.folderPath,
250
+ nextName: args.name,
251
+ });
252
+ const currentPath = join(collection.path, doc.relPath);
253
+ const nextPath = join(collection.path, plan.nextRelPath);
254
+ await mkdir(dirname(nextPath), { recursive: true });
255
+ await renameFilePath(currentPath, nextPath);
256
+ await defaultSyncService.syncCollection(collection, ctx.store, {
257
+ runUpdateCmd: false,
258
+ });
259
+ return {
260
+ uri: plan.nextUri,
261
+ relPath: plan.nextRelPath,
262
+ warnings: buildRefactorWarnings(
263
+ await getRefactorSnapshot(ctx, doc.id),
264
+ {
265
+ folderChanged: true,
266
+ filenameChanged: Boolean(args.name),
267
+ }
268
+ ).warnings,
269
+ };
270
+ });
271
+ },
272
+ (data) => `Moved note to ${data.relPath}`
273
+ );
274
+ }
275
+
276
+ export function handleDuplicateNote(
277
+ args: DuplicateNoteInput,
278
+ ctx: ToolContext
279
+ ): Promise<ToolResult> {
280
+ return runTool(
281
+ ctx,
282
+ "gno_duplicate_note",
283
+ async () => {
284
+ if (!ctx.enableWrite) {
285
+ throw new Error("Write tools disabled. Start MCP with --enable-write.");
286
+ }
287
+
288
+ return withWriteLock(ctx.writeLockPath, async () => {
289
+ const doc = await resolveDocByRef(ctx, args.ref);
290
+ ensureEditable(doc);
291
+ const collection = resolveCollection(ctx, doc.collection);
292
+ const docsResult = await ctx.store.listDocuments(collection.name);
293
+ if (!docsResult.ok) {
294
+ throw new Error(docsResult.error.message);
295
+ }
296
+ const plan = planDuplicateRefactor({
297
+ collection: collection.name,
298
+ currentRelPath: doc.relPath,
299
+ folderPath: args.folderPath,
300
+ nextName: args.name,
301
+ existingRelPaths: docsResult.value.map((entry) => entry.relPath),
302
+ });
303
+ const currentPath = join(collection.path, doc.relPath);
304
+ const nextPath = join(collection.path, plan.nextRelPath);
305
+ await mkdir(dirname(nextPath), { recursive: true });
306
+ await copyFilePath(currentPath, nextPath);
307
+ await defaultSyncService.syncCollection(collection, ctx.store, {
308
+ runUpdateCmd: false,
309
+ });
310
+ return {
311
+ uri: plan.nextUri,
312
+ relPath: plan.nextRelPath,
313
+ warnings: buildRefactorWarnings(
314
+ await getRefactorSnapshot(ctx, doc.id)
315
+ ).warnings,
316
+ };
317
+ });
318
+ },
319
+ (data) => `Duplicated note to ${data.relPath}`
320
+ );
321
+ }