roam-research-mcp 2.4.0 → 2.13.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 (53) hide show
  1. package/README.md +175 -667
  2. package/build/Roam_Markdown_Cheatsheet.md +138 -289
  3. package/build/cache/page-uid-cache.js +40 -2
  4. package/build/cli/batch/translator.js +1 -1
  5. package/build/cli/commands/batch.js +3 -8
  6. package/build/cli/commands/get.js +478 -60
  7. package/build/cli/commands/refs.js +51 -31
  8. package/build/cli/commands/save.js +61 -10
  9. package/build/cli/commands/search.js +63 -58
  10. package/build/cli/commands/status.js +3 -4
  11. package/build/cli/commands/update.js +71 -28
  12. package/build/cli/utils/graph.js +6 -2
  13. package/build/cli/utils/input.js +10 -0
  14. package/build/cli/utils/output.js +28 -5
  15. package/build/cli/utils/sort-group.js +110 -0
  16. package/build/config/graph-registry.js +31 -13
  17. package/build/config/graph-registry.test.js +42 -5
  18. package/build/markdown-utils.js +114 -4
  19. package/build/markdown-utils.test.js +125 -0
  20. package/build/query/generator.js +330 -0
  21. package/build/query/index.js +149 -0
  22. package/build/query/parser.js +319 -0
  23. package/build/query/parser.test.js +389 -0
  24. package/build/query/types.js +4 -0
  25. package/build/search/ancestor-rule.js +14 -0
  26. package/build/search/block-ref-search.js +1 -5
  27. package/build/search/hierarchy-search.js +5 -12
  28. package/build/search/index.js +1 -0
  29. package/build/search/status-search.js +10 -9
  30. package/build/search/tag-search.js +8 -24
  31. package/build/search/text-search.js +70 -27
  32. package/build/search/types.js +13 -0
  33. package/build/search/utils.js +71 -2
  34. package/build/server/roam-server.js +4 -3
  35. package/build/shared/index.js +2 -0
  36. package/build/shared/page-validator.js +233 -0
  37. package/build/shared/page-validator.test.js +128 -0
  38. package/build/shared/staged-batch.js +144 -0
  39. package/build/tools/helpers/batch-utils.js +57 -0
  40. package/build/tools/helpers/page-resolution.js +136 -0
  41. package/build/tools/helpers/refs.js +68 -0
  42. package/build/tools/operations/batch.js +75 -3
  43. package/build/tools/operations/block-retrieval.js +15 -4
  44. package/build/tools/operations/block-retrieval.test.js +87 -0
  45. package/build/tools/operations/blocks.js +1 -288
  46. package/build/tools/operations/memory.js +32 -90
  47. package/build/tools/operations/outline.js +38 -156
  48. package/build/tools/operations/pages.js +169 -122
  49. package/build/tools/operations/todos.js +5 -37
  50. package/build/tools/schemas.js +20 -9
  51. package/build/tools/tool-handlers.js +4 -4
  52. package/build/utils/helpers.js +27 -0
  53. package/package.json +1 -1
@@ -2,18 +2,24 @@ import { Command } from 'commander';
2
2
  import { PageOperations } from '../../tools/operations/pages.js';
3
3
  import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
4
4
  import { SearchOperations } from '../../tools/operations/search/index.js';
5
- import { formatPageOutput, formatBlockOutput, formatTodoOutput, printDebug, exitWithError } from '../utils/output.js';
5
+ import { formatPageOutput, formatBlockOutput, formatTodoOutput, formatGroupedOutput, flattenBlocks, blocksToMarkdown, printDebug, exitWithError } from '../utils/output.js';
6
6
  import { resolveGraph } from '../utils/graph.js';
7
+ import { readStdin } from '../utils/input.js';
7
8
  import { resolveRefs } from '../../tools/helpers/refs.js';
8
- import { resolveRelativeDate } from '../../utils/helpers.js';
9
+ import { resolveRelativeDate, parseRoamUrl, isRoamUid } from '../../utils/helpers.js';
10
+ import { SearchUtils } from '../../search/utils.js';
11
+ import { sortResults, groupResults, getDefaultDirection } from '../utils/sort-group.js';
9
12
  // Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
10
13
  const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
11
14
  /**
12
15
  * Recursively resolve block references in a RoamBlock tree
13
16
  */
14
- async function resolveBlockRefs(graph, block, maxDepth) {
15
- const resolvedString = await resolveRefs(graph, block.string, 0, maxDepth);
16
- const resolvedChildren = await Promise.all((block.children || []).map(child => resolveBlockRefs(graph, child, maxDepth)));
17
+ async function resolveBlockRefsInTree(graph, block, maxDepth) {
18
+ // Only resolve if string is valid
19
+ const resolvedString = typeof block.string === 'string'
20
+ ? await resolveRefs(graph, block.string, 0, maxDepth)
21
+ : block.string || '';
22
+ const resolvedChildren = await Promise.all((block.children || []).map(child => resolveBlockRefsInTree(graph, child, maxDepth)));
17
23
  return {
18
24
  ...block,
19
25
  string: resolvedString,
@@ -23,22 +29,186 @@ async function resolveBlockRefs(graph, block, maxDepth) {
23
29
  /**
24
30
  * Resolve refs in an array of blocks
25
31
  */
26
- async function resolveBlocksRefs(graph, blocks, maxDepth) {
27
- return Promise.all(blocks.map(block => resolveBlockRefs(graph, block, maxDepth)));
32
+ async function resolveBlocksRefsInTree(graph, blocks, maxDepth) {
33
+ return Promise.all(blocks.map(block => resolveBlockRefsInTree(graph, block, maxDepth)));
34
+ }
35
+ /**
36
+ * Normalize a tag by stripping #, [[, ]] wrappers
37
+ */
38
+ function normalizeTag(tag) {
39
+ return tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
40
+ }
41
+ /**
42
+ * Check if content contains a tag (handles #tag, [[tag]], #[[tag]] formats)
43
+ * Case-insensitive matching.
44
+ */
45
+ function contentHasTag(content, tag) {
46
+ const normalized = normalizeTag(tag).toLowerCase();
47
+ const lowerContent = content.toLowerCase();
48
+ return (lowerContent.includes(`[[${normalized}]]`) ||
49
+ lowerContent.includes(`#${normalized}`) ||
50
+ lowerContent.includes(`#[[${normalized}]]`));
51
+ }
52
+ /**
53
+ * Create the 'page' subcommand for explicit page retrieval
54
+ */
55
+ function createPageSubcommand() {
56
+ return new Command('page')
57
+ .description('Fetch a page by UID, URL, or title')
58
+ .argument('<identifier>', 'Page UID, Roam URL, or page title')
59
+ .option('-j, --json', 'Output as JSON instead of markdown')
60
+ .option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4')
61
+ .option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
62
+ .option('-f, --flat', 'Flatten hierarchy to single-level list')
63
+ .option('-u, --uid', 'Return only the page UID')
64
+ .option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
65
+ .option('--debug', 'Show query metadata')
66
+ .addHelpText('after', `
67
+ Examples:
68
+ # By page title
69
+ roam get page "Project Notes"
70
+ roam get page "January 10th, 2026"
71
+
72
+ # By page UID
73
+ roam get page abc123def
74
+
75
+ # By Roam URL (copy from browser)
76
+ roam get page "https://roamresearch.com/#/app/my-graph/page/abc123def"
77
+
78
+ # Get just the page UID
79
+ roam get page "Project Notes" --uid
80
+ `)
81
+ .action(async (identifier, options) => {
82
+ try {
83
+ const graph = resolveGraph(options, false);
84
+ const depth = parseInt(options.depth || '4', 10);
85
+ const refsDepth = options.refs !== undefined
86
+ ? Math.min(4, Math.max(1, parseInt(options.refs, 10) || 1))
87
+ : 0;
88
+ const outputOptions = {
89
+ json: options.json,
90
+ flat: options.flat,
91
+ debug: options.debug
92
+ };
93
+ if (options.debug) {
94
+ printDebug('Identifier', identifier);
95
+ printDebug('Graph', options.graph || 'default');
96
+ }
97
+ // Resolve identifier to page UID
98
+ let pageUid = null;
99
+ let pageTitle = null;
100
+ // 1. Check if it's a Roam URL
101
+ const urlParsed = parseRoamUrl(identifier);
102
+ if (urlParsed) {
103
+ pageUid = urlParsed.uid;
104
+ if (options.debug) {
105
+ printDebug('Parsed URL', { uid: pageUid, graph: urlParsed.graph });
106
+ }
107
+ }
108
+ // 2. Check if it's a direct UID
109
+ else if (isRoamUid(identifier)) {
110
+ pageUid = identifier;
111
+ if (options.debug) {
112
+ printDebug('Direct UID', pageUid);
113
+ }
114
+ }
115
+ // 3. Otherwise treat as page title
116
+ else {
117
+ pageTitle = resolveRelativeDate(identifier);
118
+ if (options.debug && pageTitle !== identifier) {
119
+ printDebug('Resolved date', `${identifier} → ${pageTitle}`);
120
+ }
121
+ }
122
+ const pageOps = new PageOperations(graph);
123
+ // If --uid flag, just return the UID
124
+ if (options.uid) {
125
+ if (pageUid) {
126
+ console.log(pageUid);
127
+ }
128
+ else if (pageTitle) {
129
+ const uid = await pageOps.getPageUid(pageTitle);
130
+ if (!uid) {
131
+ exitWithError(`Page "${pageTitle}" not found`);
132
+ }
133
+ console.log(uid);
134
+ }
135
+ return;
136
+ }
137
+ // Fetch page content
138
+ let blocks;
139
+ let displayTitle;
140
+ if (pageUid) {
141
+ // Fetch by UID - first need to get page title for display
142
+ const result = await pageOps.fetchPageByUid(pageUid);
143
+ if (!result) {
144
+ exitWithError(`Page with UID "${pageUid}" not found`);
145
+ }
146
+ blocks = result.blocks;
147
+ displayTitle = result.title;
148
+ }
149
+ else if (pageTitle) {
150
+ // Fetch by title
151
+ const result = await pageOps.fetchPageByTitle(pageTitle, 'raw');
152
+ if (typeof result === 'string') {
153
+ try {
154
+ blocks = JSON.parse(result);
155
+ }
156
+ catch {
157
+ exitWithError(result);
158
+ return;
159
+ }
160
+ }
161
+ else {
162
+ blocks = result;
163
+ }
164
+ displayTitle = pageTitle;
165
+ }
166
+ else {
167
+ exitWithError('Could not parse identifier');
168
+ return;
169
+ }
170
+ // Resolve block references if requested
171
+ if (refsDepth > 0) {
172
+ blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth);
173
+ }
174
+ console.log(formatPageOutput(displayTitle, blocks, outputOptions));
175
+ }
176
+ catch (error) {
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ exitWithError(message);
179
+ }
180
+ });
28
181
  }
29
182
  export function createGetCommand() {
30
- return new Command('get')
183
+ const cmd = new Command('get')
31
184
  .description('Fetch pages, blocks, or TODO/DONE items with optional ref expansion')
32
- .argument('[target]', 'Page title, block UID, or relative date (today/yesterday/tomorrow)')
185
+ .argument('[target]', 'Page title, block UID, or relative date. Reads from stdin if "-" or omitted.')
33
186
  .option('-j, --json', 'Output as JSON instead of markdown')
34
187
  .option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4')
35
188
  .option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
36
189
  .option('-f, --flat', 'Flatten hierarchy to single-level list')
190
+ .option('-u, --uid', 'Return only the page UID (resolve title to UID)')
37
191
  .option('--todo', 'Fetch TODO items')
38
192
  .option('--done', 'Fetch DONE items')
39
- .option('-p, --page <ref>', 'Filter TODOs/DONEs by page title or UID')
193
+ .option('-p, --page <ref>', 'Scope to page title or UID (for TODOs, tags, text)')
40
194
  .option('-i, --include <terms>', 'Include items matching these terms (comma-separated)')
41
195
  .option('-e, --exclude <terms>', 'Exclude items matching these terms (comma-separated)')
196
+ .option('--tag <tag>', 'Get blocks with tag (repeatable, comma-separated)', (val, prev) => {
197
+ const tags = val.split(',').map(t => t.trim()).filter(Boolean);
198
+ return prev ? [...prev, ...tags] : tags;
199
+ }, [])
200
+ .option('--text <text>', 'Get blocks containing text')
201
+ .option('--any', 'Use OR logic for multiple tags (default is AND)')
202
+ .option('--negtag <tag>', 'Exclude blocks with tag (repeatable, comma-separated)', (val, prev) => {
203
+ const tags = val.split(',').map(t => t.trim()).filter(Boolean);
204
+ return prev ? [...prev, ...tags] : tags;
205
+ }, [])
206
+ .option('-n, --limit <n>', 'Limit number of blocks fetched (default: 20 for tag/text)', '20')
207
+ .option('--showall', 'Show all results (no limit)')
208
+ .option('--sort <field>', 'Sort results by: created, modified, page')
209
+ .option('--asc', 'Sort ascending (default for page)')
210
+ .option('--desc', 'Sort descending (default for dates)')
211
+ .option('--group-by <field>', 'Group results by: page, tag')
42
212
  .option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
43
213
  .option('--debug', 'Show query metadata')
44
214
  .addHelpText('after', `
@@ -47,12 +217,24 @@ Examples:
47
217
  roam get "Project Notes" # Page by title
48
218
  roam get today # Today's daily page
49
219
  roam get yesterday # Yesterday's daily page
50
- roam get tomorrow # Tomorrow's daily page
220
+
221
+ # Fetch page by UID or URL (see 'roam get page --help')
222
+ roam get page abc123def # Page by UID
223
+ roam get page "https://roamresearch.com/#/app/my-graph/page/abc123def"
224
+
225
+ # Resolve page title to UID
226
+ roam get "Project Notes" --uid # Returns just the page UID
227
+ roam get today -u # Today's daily page UID
51
228
 
52
229
  # Fetch blocks
53
230
  roam get abc123def # Block by UID
54
231
  roam get "((abc123def))" # UID with wrapper
55
232
 
233
+ # Stdin / Batch Retrieval
234
+ echo "Project A" | roam get # Pipe page title
235
+ echo "abc123def" | roam get # Pipe block UID
236
+ cat uids.txt | roam get --json # Fetch multiple blocks (NDJSON output)
237
+
56
238
  # Output options
57
239
  roam get "Page" -j # JSON output
58
240
  roam get "Page" -f # Flat list (no hierarchy)
@@ -64,8 +246,42 @@ Examples:
64
246
  roam get --todo # All TODOs across graph
65
247
  roam get --done # All completed items
66
248
  roam get --todo -p "Work" # TODOs on "Work" page
67
- roam get --todo -i "urgent,blocker" # TODOs containing these terms
68
- roam get --todo -e "someday,maybe" # Exclude items with terms
249
+
250
+ # Tag-based retrieval (returns blocks with children)
251
+ roam get --tag TODO # Blocks tagged with #TODO
252
+ roam get --tag Project,Active # Blocks with both tags (AND)
253
+ roam get --tag Project --tag Active --any # Blocks with either tag (OR)
254
+ roam get --tag Task --negtag Done # Tasks excluding Done
255
+ roam get --tag Meeting -p "Work" # Meetings on Work page
256
+
257
+ # Text-based retrieval
258
+ roam get --text "urgent" # Blocks containing "urgent"
259
+ roam get --text "meeting" --tag Project # Combine text + tag filter
260
+ roam get --text "TODO" -p today # Text search on today's page
261
+
262
+ # Sorting
263
+ roam get --tag Convention --sort created # Sort by creation date (newest first)
264
+ roam get --todo --sort modified --asc # Sort by edit date (oldest first)
265
+ roam get --tag Project --sort page # Sort alphabetically by page
266
+
267
+ # Grouping
268
+ roam get --tag Convention --group-by page # Group by source page
269
+ roam get --tag Convention --group-by tag # Group by subtags (Convention/*)
270
+
271
+ # Combined
272
+ roam get --tag Convention --group-by tag --sort modified
273
+
274
+ Output format:
275
+ Markdown: Content with hierarchy (no UIDs). Use --json for UIDs.
276
+ JSON: Full block structure including uid field.
277
+
278
+ JSON output fields:
279
+ Page: { title, children: [Block...] }
280
+ Block: { uid, string, order, heading?, children: [Block...] }
281
+ TODO/DONE: [{ block_uid, content, page_title }]
282
+ Tag/Text: [{ uid, string, order, heading?, children: [...] }]
283
+
284
+ Note: For flat results with UIDs, use 'roam search' instead.
69
285
  `)
70
286
  .action(async (target, options) => {
71
287
  try {
@@ -81,76 +297,275 @@ Examples:
81
297
  debug: options.debug
82
298
  };
83
299
  if (options.debug) {
84
- printDebug('Target', target);
300
+ printDebug('Target', target || 'stdin');
85
301
  printDebug('Graph', options.graph || 'default');
86
- printDebug('Options', { depth, refs: refsDepth || 'off', ...outputOptions });
302
+ printDebug('Options', { depth, refs: refsDepth || 'off', uid: options.uid || false, ...outputOptions });
87
303
  }
88
- // Handle --todo or --done flags
304
+ // Handle --uid flag: return just the page UID
305
+ if (options.uid) {
306
+ if (!target || target === '-') {
307
+ exitWithError('--uid requires a page title argument');
308
+ }
309
+ const resolvedTarget = resolveRelativeDate(target);
310
+ if (options.debug && resolvedTarget !== target) {
311
+ printDebug('Resolved date', `${target} → ${resolvedTarget}`);
312
+ }
313
+ // Check if target is already a block UID
314
+ const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN);
315
+ if (uidMatch) {
316
+ // Already a UID, just output it
317
+ console.log(uidMatch[1]);
318
+ return;
319
+ }
320
+ const pageOps = new PageOperations(graph);
321
+ const pageUid = await pageOps.getPageUid(resolvedTarget);
322
+ if (!pageUid) {
323
+ exitWithError(`Page "${resolvedTarget}" not found`);
324
+ }
325
+ console.log(pageUid);
326
+ return;
327
+ }
328
+ // Parse sort/group options
329
+ const sortField = options.sort;
330
+ const groupByField = options.groupBy;
331
+ const sortDirection = sortField
332
+ ? (options.asc ? 'asc' : options.desc ? 'desc' : getDefaultDirection(sortField))
333
+ : undefined;
334
+ // Handle --todo or --done flags (these ignore target arg usually, but could filter by page if target is used as page?)
335
+ // The help says "-p" is for page. So we strictly follow flags.
89
336
  if (options.todo || options.done) {
90
337
  const status = options.todo ? 'TODO' : 'DONE';
91
338
  if (options.debug) {
92
339
  printDebug('Status search', { status, page: options.page, include: options.include, exclude: options.exclude });
340
+ if (sortField)
341
+ printDebug('Sort', { field: sortField, direction: sortDirection });
342
+ if (groupByField)
343
+ printDebug('Group by', groupByField);
93
344
  }
94
345
  const searchOps = new SearchOperations(graph);
95
346
  const result = await searchOps.searchByStatus(status, options.page, options.include, options.exclude);
96
- console.log(formatTodoOutput(result.matches, status, outputOptions));
347
+ let matches = result.matches;
348
+ // Apply sorting
349
+ if (sortField && sortDirection) {
350
+ matches = sortResults(matches, { field: sortField, direction: sortDirection });
351
+ }
352
+ // Apply grouping
353
+ if (groupByField) {
354
+ // For TODO/DONE, only page grouping makes sense (no tags on search results)
355
+ if (groupByField === 'tag') {
356
+ exitWithError('--group-by tag is not supported for TODO/DONE search. Use --group-by page instead.');
357
+ }
358
+ const grouped = groupResults(matches, { by: groupByField });
359
+ console.log(formatGroupedOutput(grouped, outputOptions));
360
+ }
361
+ else {
362
+ console.log(formatTodoOutput(matches, status, outputOptions));
363
+ }
97
364
  return;
98
365
  }
99
- // For page/block fetching, target is required
100
- if (!target) {
101
- exitWithError('Target is required. Use: roam get <page-title> or roam get --todo');
102
- }
103
- // Resolve relative date keywords (today, yesterday, tomorrow)
104
- const resolvedTarget = resolveRelativeDate(target);
105
- if (options.debug && resolvedTarget !== target) {
106
- printDebug('Resolved date', `${target} → ${resolvedTarget}`);
107
- }
108
- // Check if target is a block UID
109
- const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN);
110
- if (uidMatch) {
111
- // Fetch block by UID
112
- const blockUid = uidMatch[1];
366
+ // Handle --tag and/or --text flags (search-based retrieval with full children)
367
+ const tags = options.tag || [];
368
+ if (tags.length > 0 || options.text) {
369
+ const searchOps = new SearchOperations(graph);
370
+ const blockOps = new BlockRetrievalOperations(graph);
371
+ const limit = options.showall ? Infinity : parseInt(options.limit || '20', 10);
372
+ const useOrLogic = options.any || false;
373
+ // Resolve page scope
374
+ const pageScope = options.page ? resolveRelativeDate(options.page) : undefined;
113
375
  if (options.debug) {
114
- printDebug('Fetching block', { uid: blockUid, depth });
376
+ printDebug('Tag/Text search', {
377
+ tags,
378
+ text: options.text,
379
+ page: pageScope,
380
+ any: useOrLogic,
381
+ negtag: options.negtag,
382
+ limit
383
+ });
384
+ if (sortField)
385
+ printDebug('Sort', { field: sortField, direction: sortDirection });
386
+ if (groupByField)
387
+ printDebug('Group by', groupByField);
115
388
  }
116
- const blockOps = new BlockRetrievalOperations(graph);
117
- let block = await blockOps.fetchBlockWithChildren(blockUid, depth);
118
- if (!block) {
119
- exitWithError(`Block with UID "${blockUid}" not found`);
389
+ // Get initial matches
390
+ let matches = [];
391
+ if (options.text) {
392
+ // Text search
393
+ const result = await searchOps.searchByText({
394
+ text: options.text,
395
+ page_title_uid: pageScope
396
+ });
397
+ matches = result.matches;
398
+ }
399
+ else if (tags.length > 0) {
400
+ // Tag search (use first tag as primary)
401
+ const normalizedTags = tags.map(normalizeTag);
402
+ const result = await searchOps.searchForTag(normalizedTags[0], pageScope);
403
+ matches = result.matches;
404
+ }
405
+ // Apply additional tag filters
406
+ if (tags.length > 0 && matches.length > 0) {
407
+ const normalizedTags = tags.map(normalizeTag);
408
+ // For text search with tags, filter by ALL tags
409
+ // For tag search with multiple tags, filter by remaining tags based on --any
410
+ if (options.text || normalizedTags.length > 1) {
411
+ matches = matches.filter(m => {
412
+ if (useOrLogic) {
413
+ return normalizedTags.some(tag => contentHasTag(m.content, tag));
414
+ }
415
+ else {
416
+ return normalizedTags.every(tag => contentHasTag(m.content, tag));
417
+ }
418
+ });
419
+ }
420
+ }
421
+ // Apply negative tag filter
422
+ const negTags = options.negtag || [];
423
+ if (negTags.length > 0) {
424
+ const normalizedNegTags = negTags.map(normalizeTag);
425
+ matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag)));
426
+ }
427
+ // Apply sorting before limit (so we get the top N sorted items)
428
+ if (sortField && sortDirection) {
429
+ matches = sortResults(matches, { field: sortField, direction: sortDirection });
430
+ }
431
+ // Apply limit
432
+ const limitedMatches = matches.slice(0, limit);
433
+ if (limitedMatches.length === 0) {
434
+ console.log(options.json ? '[]' : 'No blocks found matching criteria.');
435
+ return;
436
+ }
437
+ // For tag grouping, fetch all tags for matched blocks
438
+ if (groupByField === 'tag') {
439
+ const blockUids = limitedMatches.map(m => m.block_uid);
440
+ const tagMap = await SearchUtils.fetchBlockTags(graph, blockUids);
441
+ // Attach tags to matches
442
+ for (const match of limitedMatches) {
443
+ match.tags = tagMap.get(match.block_uid) || [];
444
+ }
445
+ // Group and output
446
+ const primaryTag = tags.length > 0 ? normalizeTag(tags[0]) : '';
447
+ const grouped = groupResults(limitedMatches, { by: 'tag', searchTag: primaryTag });
448
+ console.log(formatGroupedOutput(grouped, outputOptions));
449
+ return;
120
450
  }
121
- // Resolve block references if requested
122
- if (refsDepth > 0) {
123
- block = await resolveBlockRefs(graph, block, refsDepth);
451
+ // For page grouping, output grouped matches
452
+ if (groupByField === 'page') {
453
+ const grouped = groupResults(limitedMatches, { by: 'page' });
454
+ console.log(formatGroupedOutput(grouped, outputOptions));
455
+ return;
124
456
  }
125
- console.log(formatBlockOutput(block, outputOptions));
457
+ // Standard output: fetch full blocks with children
458
+ const blocks = [];
459
+ for (const match of limitedMatches) {
460
+ let block = await blockOps.fetchBlockWithChildren(match.block_uid, depth);
461
+ if (block) {
462
+ // Resolve refs if requested (default: enabled for tag/text search)
463
+ const effectiveRefsDepth = refsDepth > 0 ? refsDepth : 1;
464
+ block = await resolveBlockRefsInTree(graph, block, effectiveRefsDepth);
465
+ blocks.push(block);
466
+ }
467
+ }
468
+ // Output
469
+ if (options.json) {
470
+ const data = options.flat
471
+ ? blocks.flatMap(b => flattenBlocks([b]))
472
+ : blocks;
473
+ console.log(JSON.stringify(data, null, 2));
474
+ }
475
+ else {
476
+ const displayBlocks = options.flat
477
+ ? blocks.flatMap(b => flattenBlocks([b]))
478
+ : blocks;
479
+ // Show count header
480
+ const countMsg = matches.length > limit
481
+ ? `Found ${matches.length} blocks (showing first ${limit}):\n\n`
482
+ : `Found ${blocks.length} block(s):\n\n`;
483
+ console.log(countMsg + blocksToMarkdown(displayBlocks));
484
+ }
485
+ return;
486
+ }
487
+ // Determine targets
488
+ let targets = [];
489
+ if (target && target !== '-') {
490
+ targets = [target];
126
491
  }
127
492
  else {
128
- // Fetch page by title
129
- if (options.debug) {
130
- printDebug('Fetching page', { title: resolvedTarget, depth });
493
+ // Read from stdin if no target or explicit '-'
494
+ if (process.stdin.isTTY && target !== '-') {
495
+ // If TTY and no target, show error
496
+ exitWithError('Target is required. Use: roam get <page-title>, roam get --todo, roam get --tag <tag>, roam get --text <text>, or pipe targets via stdin');
131
497
  }
132
- const pageOps = new PageOperations(graph);
133
- const result = await pageOps.fetchPageByTitle(resolvedTarget, 'raw');
134
- // Parse the raw result
135
- let blocks;
136
- if (typeof result === 'string') {
137
- try {
138
- blocks = JSON.parse(result);
498
+ const input = await readStdin();
499
+ if (input) {
500
+ targets = input.split('\n').map(t => t.trim()).filter(Boolean);
501
+ }
502
+ }
503
+ if (targets.length === 0) {
504
+ exitWithError('No targets provided');
505
+ }
506
+ // Helper to process a single target
507
+ const processTarget = async (item) => {
508
+ // Resolve relative date keywords (today, yesterday, tomorrow)
509
+ const resolvedTarget = resolveRelativeDate(item);
510
+ if (options.debug && resolvedTarget !== item) {
511
+ printDebug('Resolved date', `${item} → ${resolvedTarget}`);
512
+ }
513
+ // Check if target is a block UID
514
+ const uidMatch = resolvedTarget.match(BLOCK_UID_PATTERN);
515
+ if (uidMatch) {
516
+ // Fetch block by UID
517
+ const blockUid = uidMatch[1];
518
+ if (options.debug)
519
+ printDebug('Fetching block', { uid: blockUid });
520
+ const blockOps = new BlockRetrievalOperations(graph);
521
+ let block = await blockOps.fetchBlockWithChildren(blockUid, depth);
522
+ if (!block) {
523
+ // If fetching multiple, maybe warn instead of exit?
524
+ // For now, consistent behavior: print error message to stderr but continue?
525
+ // Or simpler: just return a "not found" string/object.
526
+ // formatBlockOutput doesn't handle null.
527
+ return options.json ? JSON.stringify({ error: `Block ${blockUid} not found` }) : `Block ${blockUid} not found`;
139
528
  }
140
- catch {
141
- // Result is already formatted as string (e.g., "Page Title (no content found)")
142
- console.log(result);
143
- return;
529
+ // Resolve block references if requested
530
+ if (refsDepth > 0) {
531
+ block = await resolveBlockRefsInTree(graph, block, refsDepth);
144
532
  }
533
+ return formatBlockOutput(block, outputOptions);
145
534
  }
146
535
  else {
147
- blocks = result;
148
- }
149
- // Resolve block references if requested
150
- if (refsDepth > 0) {
151
- blocks = await resolveBlocksRefs(graph, blocks, refsDepth);
536
+ // Fetch page by title
537
+ if (options.debug)
538
+ printDebug('Fetching page', { title: resolvedTarget });
539
+ const pageOps = new PageOperations(graph);
540
+ const result = await pageOps.fetchPageByTitle(resolvedTarget, 'raw');
541
+ // Parse the raw result
542
+ let blocks;
543
+ if (typeof result === 'string') {
544
+ try {
545
+ blocks = JSON.parse(result);
546
+ }
547
+ catch {
548
+ // Result is already formatted as string (e.g., "Page Title (no content found)")
549
+ // But wait, fetchPageByTitle returns string if not found or empty?
550
+ // Actually fetchPageByTitle 'raw' returns JSON string of blocks OR empty array JSON string?
551
+ // Let's assume result is valid JSON or error message string.
552
+ return options.json ? JSON.stringify({ title: resolvedTarget, error: result }) : result;
553
+ }
554
+ }
555
+ else {
556
+ blocks = result;
557
+ }
558
+ // Resolve block references if requested
559
+ if (refsDepth > 0) {
560
+ blocks = await resolveBlocksRefsInTree(graph, blocks, refsDepth);
561
+ }
562
+ return formatPageOutput(resolvedTarget, blocks, outputOptions);
152
563
  }
153
- console.log(formatPageOutput(resolvedTarget, blocks, outputOptions));
564
+ };
565
+ // Execute sequentially
566
+ for (const t of targets) {
567
+ const output = await processTarget(t);
568
+ console.log(output);
154
569
  }
155
570
  }
156
571
  catch (error) {
@@ -158,4 +573,7 @@ Examples:
158
573
  exitWithError(message);
159
574
  }
160
575
  });
576
+ // Add subcommands
577
+ cmd.addCommand(createPageSubcommand());
578
+ return cmd;
161
579
  }