roam-research-mcp 1.6.0 → 2.4.3

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.
@@ -1,11 +1,15 @@
1
1
  import { Command } from 'commander';
2
- import { readFileSync } from 'fs';
2
+ import { existsSync, readFileSync } from 'fs';
3
3
  import { basename } from 'path';
4
- import { initializeGraph } from '@roam-research/roam-api-sdk';
5
- import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
6
4
  import { PageOperations } from '../../tools/operations/pages.js';
7
- import { parseMarkdown } from '../../markdown-utils.js';
5
+ import { TodoOperations } from '../../tools/operations/todos.js';
6
+ import { BatchOperations } from '../../tools/operations/batch.js';
7
+ import { parseMarkdown, generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
8
8
  import { printDebug, exitWithError } from '../utils/output.js';
9
+ import { resolveGraph } from '../utils/graph.js';
10
+ import { readStdin } from '../utils/input.js';
11
+ import { formatRoamDate } from '../../utils/helpers.js';
12
+ import { q, createPage as roamCreatePage } from '@roam-research/roam-api-sdk';
9
13
  /**
10
14
  * Flatten nested MarkdownNode[] to flat array with absolute levels
11
15
  */
@@ -24,94 +28,463 @@ function flattenNodes(nodes, baseLevel = 1) {
24
28
  return result;
25
29
  }
26
30
  /**
27
- * Read all input from stdin
31
+ * Check if a string looks like a Roam block UID (9 alphanumeric chars with _ or -)
28
32
  */
29
- async function readStdin() {
30
- const chunks = [];
31
- for await (const chunk of process.stdin) {
32
- chunks.push(chunk);
33
+ function isBlockUid(value) {
34
+ // Strip (( )) wrapper if present
35
+ const cleaned = value.replace(/^\(\(|\)\)$/g, '');
36
+ return /^[a-zA-Z0-9_-]{9}$/.test(cleaned);
37
+ }
38
+ /**
39
+ * Check if content looks like a JSON array
40
+ */
41
+ function looksLikeJsonArray(content) {
42
+ const trimmed = content.trim();
43
+ return trimmed.startsWith('[') && trimmed.endsWith(']');
44
+ }
45
+ /**
46
+ * Find or create a page by title, returns UID
47
+ */
48
+ async function findOrCreatePage(graph, title) {
49
+ const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
50
+ const findResults = await q(graph, findQuery, [title]);
51
+ if (findResults && findResults.length > 0) {
52
+ return findResults[0][0];
33
53
  }
34
- return Buffer.concat(chunks).toString('utf-8');
54
+ // Create the page if it doesn't exist
55
+ await roamCreatePage(graph, {
56
+ action: 'create-page',
57
+ page: { title }
58
+ });
59
+ const results = await q(graph, findQuery, [title]);
60
+ if (!results || results.length === 0) {
61
+ throw new Error(`Could not find created page: ${title}`);
62
+ }
63
+ return results[0][0];
64
+ }
65
+ /**
66
+ * Get or create today's daily page UID
67
+ */
68
+ async function getDailyPageUid(graph) {
69
+ const today = new Date();
70
+ const dateStr = formatRoamDate(today);
71
+ return findOrCreatePage(graph, dateStr);
72
+ }
73
+ /**
74
+ * Find or create a heading block on a page
75
+ */
76
+ async function findOrCreateHeading(graph, pageUid, heading, headingLevel) {
77
+ // Search for existing heading block
78
+ const headingQuery = `[:find ?uid
79
+ :in $ ?page-uid ?text
80
+ :where
81
+ [?page :block/uid ?page-uid]
82
+ [?page :block/children ?block]
83
+ [?block :block/string ?text]
84
+ [?block :block/uid ?uid]]`;
85
+ const headingResults = await q(graph, headingQuery, [pageUid, heading]);
86
+ if (headingResults && headingResults.length > 0) {
87
+ return headingResults[0][0];
88
+ }
89
+ // Create the heading block
90
+ const batchOps = new BatchOperations(graph);
91
+ const headingUid = generateBlockUid();
92
+ await batchOps.processBatch([{
93
+ action: 'create-block',
94
+ location: { 'parent-uid': pageUid, order: 'last' },
95
+ string: heading,
96
+ uid: headingUid,
97
+ ...(headingLevel && { heading: headingLevel })
98
+ }]);
99
+ return headingUid;
100
+ }
101
+ /**
102
+ * Parse JSON content blocks
103
+ */
104
+ function parseJsonContent(content) {
105
+ const parsed = JSON.parse(content);
106
+ if (!Array.isArray(parsed)) {
107
+ throw new Error('JSON content must be an array of {text, level, heading?} objects');
108
+ }
109
+ return parsed.map((item, index) => {
110
+ if (typeof item.text !== 'string' || typeof item.level !== 'number') {
111
+ throw new Error(`Invalid item at index ${index}: must have "text" (string) and "level" (number)`);
112
+ }
113
+ // If heading not explicitly provided, detect from text and strip hashes
114
+ if (item.heading) {
115
+ return {
116
+ text: item.text,
117
+ level: item.level,
118
+ heading: item.heading
119
+ };
120
+ }
121
+ const { heading_level, content: strippedText } = parseMarkdownHeadingLevel(item.text);
122
+ return {
123
+ text: strippedText,
124
+ level: item.level,
125
+ ...(heading_level > 0 && { heading: heading_level })
126
+ };
127
+ });
35
128
  }
36
129
  export function createSaveCommand() {
37
130
  return new Command('save')
38
- .description('Import markdown to Roam')
39
- .argument('[file]', 'Markdown file to import (or pipe content to stdin)')
40
- .option('--title <title>', 'Page title (defaults to filename without .md)')
41
- .option('--update', 'Update existing page using smart diff')
131
+ .description('Save text, files, or JSON to pages/blocks. Auto-detects format.')
132
+ .argument('[input]', 'Text, file path, or "-" for stdin (auto-detected)')
133
+ .option('--title <title>', 'Create a new page with this title')
134
+ .option('--update', 'Update existing page using smart diff (preserves block UIDs)')
135
+ .option('-p, --page <ref>', 'Target page by title or UID (default: daily page, creates if missing)')
136
+ .option('--parent <ref>', 'Nest under block UID ((uid)) or heading text (creates if missing). Use # prefix for heading level: "## Section"')
137
+ .option('-c, --categories <tags>', 'Comma-separated tags appended to first block')
138
+ .option('-t, --todo [text]', 'Add TODO item(s) to daily page. Accepts inline text or stdin')
139
+ .option('--json', 'Force JSON array format: [{text, level, heading?}, ...]')
140
+ .option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
141
+ .option('--write-key <key>', 'Write confirmation key (non-default graphs)')
42
142
  .option('--debug', 'Show debug information')
43
- .action(async (file, options) => {
143
+ .addHelpText('after', `
144
+ Examples:
145
+ # Quick saves to daily page
146
+ roam save "Quick note" # Single block
147
+ roam save "# Important" -c "work,urgent" # H1 heading with tags
148
+ roam save --todo "Buy groceries" # TODO item
149
+
150
+ # Save under heading (creates if missing)
151
+ roam save --parent "## Notes" "My note" # Under H2 "Notes" heading
152
+ roam save --parent "((blockUid9))" "Child" # Under specific block
153
+
154
+ # Target specific page
155
+ roam save -p "Project X" "Status update" # By title (creates if missing)
156
+ roam save -p "pageUid123" "Note" # By UID
157
+
158
+ # File operations
159
+ roam save notes.md --title "My Notes" # Create page from file
160
+ roam save notes.md --title "My Notes" --update # Smart update (preserves UIDs)
161
+ cat data.json | roam save --json # Pipe JSON blocks
162
+
163
+ # Stdin operations
164
+ echo "Task from CLI" | roam save --todo # Pipe to TODO
165
+ cat note.md | roam save --title "From Pipe" # Pipe file content to new page
166
+ echo "Quick capture" | roam save -p "Inbox" # Pipe to specific page
167
+
168
+ # Combine options
169
+ roam save -p "Work" --parent "## Today" "Done with task" -c "wins"
170
+
171
+ JSON format (--json):
172
+ Array of blocks with text, level, and optional heading:
173
+ [
174
+ {"text": "# Main Title", "level": 1}, # Auto-detects H1
175
+ {"text": "Subheading", "level": 1, "heading": 2}, # Explicit H2
176
+ {"text": "Nested content", "level": 2}, # Child block
177
+ {"text": "Sibling", "level": 2}
178
+ ]
179
+ `)
180
+ .action(async (input, options) => {
44
181
  try {
45
- let markdownContent;
46
- let pageTitle;
47
- if (file) {
48
- // Read from file
182
+ // TODO mode: add a TODO item to today's daily page
183
+ if (options.todo !== undefined) {
184
+ let todoText;
185
+ if (typeof options.todo === 'string' && options.todo.length > 0) {
186
+ todoText = options.todo;
187
+ }
188
+ else {
189
+ if (process.stdin.isTTY) {
190
+ exitWithError('No TODO text. Use: roam save --todo "text" or echo "text" | roam save --todo');
191
+ }
192
+ todoText = (await readStdin()).trim();
193
+ }
194
+ if (!todoText) {
195
+ exitWithError('Empty TODO text');
196
+ }
197
+ const todos = todoText.split('\n').map(t => t.trim()).filter(Boolean);
198
+ if (options.debug) {
199
+ printDebug('TODO mode', true);
200
+ printDebug('Graph', options.graph || 'default');
201
+ printDebug('TODO items', todos);
202
+ }
203
+ const graph = resolveGraph(options, true);
204
+ const todoOps = new TodoOperations(graph);
205
+ const result = await todoOps.addTodos(todos);
206
+ if (result.success) {
207
+ console.log(`Added ${todos.length} TODO item(s) to today's daily page`);
208
+ }
209
+ else {
210
+ exitWithError('Failed to save TODO');
211
+ }
212
+ return;
213
+ }
214
+ // Determine content source: file, text argument, or stdin
215
+ let content;
216
+ let isFile = false;
217
+ let sourceFilename;
218
+ if (input && input !== '-') {
219
+ // Check if input is a file path that exists
220
+ if (existsSync(input)) {
221
+ isFile = true;
222
+ sourceFilename = input;
223
+ try {
224
+ content = readFileSync(input, 'utf-8');
225
+ }
226
+ catch (err) {
227
+ exitWithError(`Could not read file: ${input}`);
228
+ }
229
+ }
230
+ else {
231
+ // Treat as text content
232
+ content = input;
233
+ }
234
+ }
235
+ else {
236
+ // Read from stdin (or if input is explicit '-')
237
+ if (process.stdin.isTTY && input !== '-') {
238
+ exitWithError('No input. Use: roam save "text", roam save <file>, or pipe content');
239
+ }
240
+ content = await readStdin();
241
+ }
242
+ content = content.trim();
243
+ if (!content) {
244
+ exitWithError('Empty content');
245
+ }
246
+ // Determine format: JSON or markdown/text
247
+ const isJson = options.json || (isFile && sourceFilename?.endsWith('.json')) || looksLikeJsonArray(content);
248
+ // Parse content into blocks
249
+ let contentBlocks;
250
+ if (isJson) {
49
251
  try {
50
- markdownContent = readFileSync(file, 'utf-8');
252
+ contentBlocks = parseJsonContent(content);
51
253
  }
52
254
  catch (err) {
53
- exitWithError(`Could not read file: ${file}`);
255
+ exitWithError(err instanceof Error ? err.message : 'Invalid JSON');
54
256
  }
55
- // Derive title from filename if not provided
56
- pageTitle = options.title || basename(file, '.md');
257
+ }
258
+ else if (isFile || content.includes('\n')) {
259
+ // Multi-line content: parse as markdown
260
+ const nodes = parseMarkdown(content);
261
+ contentBlocks = flattenNodes(nodes);
57
262
  }
58
263
  else {
59
- // Read from stdin
60
- if (process.stdin.isTTY) {
61
- exitWithError('No file specified and no input piped. Use: roam save <file.md> or cat file.md | roam save --title "Title"');
264
+ // Single line text: detect heading syntax and strip hashes
265
+ const { heading_level, content: strippedContent } = parseMarkdownHeadingLevel(content);
266
+ contentBlocks = [{
267
+ text: strippedContent,
268
+ level: 1,
269
+ ...(heading_level > 0 && { heading: heading_level })
270
+ }];
271
+ }
272
+ if (contentBlocks.length === 0) {
273
+ exitWithError('No content blocks parsed');
274
+ }
275
+ // Parse categories
276
+ const categories = options.categories
277
+ ? options.categories.split(',').map(c => c.trim()).filter(Boolean)
278
+ : undefined;
279
+ // Determine parent type if specified
280
+ let parentUid;
281
+ let parentHeading;
282
+ let parentHeadingLevel;
283
+ if (options.parent) {
284
+ const cleanedParent = options.parent.replace(/^\(\(|\)\)$/g, '');
285
+ if (isBlockUid(cleanedParent)) {
286
+ parentUid = cleanedParent;
62
287
  }
63
- if (!options.title) {
64
- exitWithError('--title is required when piping from stdin');
288
+ else {
289
+ // Parse heading syntax from parent text
290
+ const { heading_level, content } = parseMarkdownHeadingLevel(options.parent);
291
+ parentHeading = content;
292
+ if (heading_level > 0) {
293
+ parentHeadingLevel = heading_level;
294
+ }
65
295
  }
66
- markdownContent = await readStdin();
67
- pageTitle = options.title;
68
- }
69
- if (!markdownContent.trim()) {
70
- exitWithError('Empty content received');
71
296
  }
72
297
  if (options.debug) {
73
- printDebug('Page title', pageTitle);
74
- printDebug('Content length', markdownContent.length);
75
- printDebug('Update mode', options.update || false);
76
- }
77
- const graph = initializeGraph({
78
- token: API_TOKEN,
79
- graph: GRAPH_NAME
80
- });
81
- const pageOps = new PageOperations(graph);
82
- if (options.update) {
83
- // Use smart diff to update existing page
84
- const result = await pageOps.updatePageMarkdown(pageTitle, markdownContent, false // not dry run
85
- );
86
- if (result.success) {
87
- console.log(`Updated page '${pageTitle}'`);
88
- console.log(` ${result.summary}`);
89
- if (result.preservedUids.length > 0) {
90
- console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
298
+ printDebug('Input', input || 'stdin');
299
+ printDebug('Is file', isFile);
300
+ printDebug('Is JSON', isJson);
301
+ printDebug('Graph', options.graph || 'default');
302
+ printDebug('Content blocks', contentBlocks.length);
303
+ printDebug('Parent UID', parentUid || 'none');
304
+ printDebug('Parent heading', parentHeading || 'none');
305
+ printDebug('Target page', options.page || 'daily page');
306
+ printDebug('Categories', categories || 'none');
307
+ printDebug('Title', options.title || 'none');
308
+ }
309
+ const graph = resolveGraph(options, true);
310
+ // Determine operation mode based on options
311
+ const hasParent = parentUid || parentHeading;
312
+ const hasTitle = options.title;
313
+ const wantsPage = hasTitle && !hasParent;
314
+ if (wantsPage || (isFile && !hasParent)) {
315
+ // PAGE MODE: create a page
316
+ const pageTitle = options.title || (sourceFilename ? basename(sourceFilename, '.md').replace('.json', '') : undefined);
317
+ if (!pageTitle) {
318
+ exitWithError('--title required for page creation from stdin');
319
+ }
320
+ const pageOps = new PageOperations(graph);
321
+ if (options.update) {
322
+ if (isJson) {
323
+ exitWithError('--update is not supported with JSON content');
324
+ }
325
+ const result = await pageOps.updatePageMarkdown(pageTitle, content, false);
326
+ if (result.success) {
327
+ console.log(`Updated page '${pageTitle}'`);
328
+ console.log(` ${result.summary}`);
329
+ if (result.preservedUids.length > 0) {
330
+ console.log(` Preserved ${result.preservedUids.length} block UID(s)`);
331
+ }
332
+ }
333
+ else {
334
+ exitWithError(`Failed to update page '${pageTitle}'`);
91
335
  }
92
336
  }
93
337
  else {
94
- exitWithError(`Failed to update page '${pageTitle}'`);
338
+ const result = await pageOps.createPage(pageTitle, contentBlocks);
339
+ if (result.success) {
340
+ console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
341
+ }
342
+ else {
343
+ exitWithError(`Failed to create page '${pageTitle}'`);
344
+ }
95
345
  }
346
+ return;
347
+ }
348
+ // BLOCK MODE: add content under parent or to daily page
349
+ if (parentUid) {
350
+ // Direct parent UID: use batch operations
351
+ const batchOps = new BatchOperations(graph);
352
+ // Build batch actions for all content blocks
353
+ const actions = [];
354
+ const uidMap = {};
355
+ for (let i = 0; i < contentBlocks.length; i++) {
356
+ const block = contentBlocks[i];
357
+ const uidPlaceholder = `block-${i}`;
358
+ uidMap[i] = uidPlaceholder;
359
+ // Determine parent for this block
360
+ let blockParent;
361
+ if (block.level === 1) {
362
+ blockParent = parentUid;
363
+ }
364
+ else {
365
+ // Find the closest ancestor at level - 1
366
+ let ancestorIndex = i - 1;
367
+ while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
368
+ ancestorIndex--;
369
+ }
370
+ if (ancestorIndex >= 0) {
371
+ blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
372
+ }
373
+ else {
374
+ blockParent = parentUid;
375
+ }
376
+ }
377
+ actions.push({
378
+ action: 'create-block',
379
+ location: {
380
+ 'parent-uid': blockParent,
381
+ order: 'last'
382
+ },
383
+ string: block.text,
384
+ uid: `{{uid:${uidPlaceholder}}}`,
385
+ ...(block.heading && { heading: block.heading })
386
+ });
387
+ }
388
+ const result = await batchOps.processBatch(actions);
389
+ if (result.success && result.uid_map) {
390
+ // Output the first block's UID
391
+ console.log(result.uid_map['block-0']);
392
+ }
393
+ else {
394
+ const errorMsg = typeof result.error === 'string'
395
+ ? result.error
396
+ : result.error?.message || 'Unknown error';
397
+ exitWithError(`Failed to save: ${errorMsg}`);
398
+ }
399
+ return;
400
+ }
401
+ // Parent heading or target page: get target page UID first
402
+ let pageUid;
403
+ if (options.page) {
404
+ // Strip (( )) wrapper if UID, but NOT [[ ]] (that's valid page title syntax)
405
+ const cleanedPage = options.page.replace(/^\(\(|\)\)$/g, '');
406
+ if (isBlockUid(cleanedPage)) {
407
+ pageUid = cleanedPage;
408
+ }
409
+ else {
410
+ pageUid = await findOrCreatePage(graph, options.page);
411
+ }
412
+ }
413
+ else {
414
+ pageUid = await getDailyPageUid(graph);
415
+ }
416
+ if (options.debug) {
417
+ printDebug('Target page UID', pageUid);
418
+ }
419
+ // Resolve heading to parent UID if specified
420
+ let targetParentUid;
421
+ if (parentHeading) {
422
+ targetParentUid = await findOrCreateHeading(graph, pageUid, parentHeading, parentHeadingLevel);
96
423
  }
97
424
  else {
98
- // Create new page (or add content to existing empty page)
99
- const nodes = parseMarkdown(markdownContent);
100
- const contentBlocks = flattenNodes(nodes);
101
- if (contentBlocks.length === 0) {
102
- exitWithError('No content blocks parsed from input');
425
+ targetParentUid = pageUid;
426
+ }
427
+ // Format categories as Roam tags if provided
428
+ const categoryTags = categories?.map(cat => {
429
+ return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
430
+ }).join(' ') || '';
431
+ // Create all blocks using batch operations
432
+ const batchOps = new BatchOperations(graph);
433
+ const actions = [];
434
+ const uidMap = {};
435
+ for (let i = 0; i < contentBlocks.length; i++) {
436
+ const block = contentBlocks[i];
437
+ const uidPlaceholder = `block-${i}`;
438
+ uidMap[i] = uidPlaceholder;
439
+ // Determine parent for this block
440
+ let blockParent;
441
+ if (block.level === 1) {
442
+ blockParent = targetParentUid;
103
443
  }
104
- if (options.debug) {
105
- printDebug('Parsed blocks', contentBlocks.length);
444
+ else {
445
+ // Find the closest ancestor at level - 1
446
+ let ancestorIndex = i - 1;
447
+ while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
448
+ ancestorIndex--;
449
+ }
450
+ if (ancestorIndex >= 0) {
451
+ blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
452
+ }
453
+ else {
454
+ blockParent = targetParentUid;
455
+ }
106
456
  }
107
- const result = await pageOps.createPage(pageTitle, contentBlocks);
108
- if (result.success) {
109
- console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
457
+ // Append category tags to first block only
458
+ const blockText = i === 0 && categoryTags
459
+ ? `${block.text} ${categoryTags}`
460
+ : block.text;
461
+ actions.push({
462
+ action: 'create-block',
463
+ location: {
464
+ 'parent-uid': blockParent,
465
+ order: 'last'
466
+ },
467
+ string: blockText,
468
+ uid: `{{uid:${uidPlaceholder}}}`,
469
+ ...(block.heading && { heading: block.heading })
470
+ });
471
+ }
472
+ const result = await batchOps.processBatch(actions);
473
+ if (result.success && result.uid_map) {
474
+ // Output first block UID (and parent if heading was used)
475
+ if (parentHeading) {
476
+ console.log(`${result.uid_map['block-0']} ${targetParentUid}`);
110
477
  }
111
478
  else {
112
- exitWithError(`Failed to create page '${pageTitle}'`);
479
+ console.log(result.uid_map['block-0']);
113
480
  }
114
481
  }
482
+ else {
483
+ const errorMsg = typeof result.error === 'string'
484
+ ? result.error
485
+ : result.error?.message || 'Unknown error';
486
+ exitWithError(`Failed to save: ${errorMsg}`);
487
+ }
115
488
  }
116
489
  catch (error) {
117
490
  const message = error instanceof Error ? error.message : String(error);