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,6 +2,7 @@ import { Command } from 'commander';
2
2
  import { SearchOperations } from '../../tools/operations/search/index.js';
3
3
  import { printDebug, exitWithError } from '../utils/output.js';
4
4
  import { resolveGraph } from '../utils/graph.js';
5
+ import { readStdin } from '../utils/input.js';
5
6
  /**
6
7
  * Format results grouped by page (default output)
7
8
  */
@@ -72,7 +73,7 @@ function parseIdentifier(identifier) {
72
73
  export function createRefsCommand() {
73
74
  return new Command('refs')
74
75
  .description('Find all blocks that reference a page, tag, or block')
75
- .argument('<identifier>', 'Page title, #tag, [[Page]], or ((block-uid))')
76
+ .argument('[identifier]', 'Page title, #tag, [[Page]], or ((block-uid)). Reads from stdin if "-" or omitted.')
76
77
  .option('-n, --limit <n>', 'Limit number of results', '50')
77
78
  .option('--json', 'Output as JSON array')
78
79
  .option('--raw', 'Output raw UID + content lines (no grouping)')
@@ -82,49 +83,68 @@ export function createRefsCommand() {
82
83
  Examples:
83
84
  # Page references
84
85
  roam refs "Project Alpha" # Blocks linking to page
85
- roam refs "[[Meeting Notes]]" # With bracket syntax
86
86
  roam refs "#TODO" # Blocks with #TODO tag
87
87
 
88
+ # Stdin / Batch references
89
+ echo "Project A" | roam refs # Pipe page title
90
+ cat uids.txt | roam refs --json # Find refs for multiple UIDs
91
+
88
92
  # Block references
89
93
  roam refs "((abc123def))" # Blocks embedding this block
90
94
 
91
- # Output options
92
- roam refs "Work" --json # JSON array output
93
- roam refs "Ideas" --raw # Raw UID + content (no grouping)
94
- roam refs "Tasks" -n 100 # Limit to 100 results
95
+ JSON output fields: uid, content, page
95
96
  `)
96
97
  .action(async (identifier, options) => {
97
98
  try {
98
99
  const graph = resolveGraph(options, false);
99
100
  const limit = parseInt(options.limit || '50', 10);
100
- const { block_uid, title } = parseIdentifier(identifier);
101
- if (options.debug) {
102
- printDebug('Identifier', identifier);
103
- printDebug('Graph', options.graph || 'default');
104
- printDebug('Parsed', { block_uid, title });
105
- printDebug('Options', options);
106
- }
107
- const searchOps = new SearchOperations(graph);
108
- const result = await searchOps.searchBlockRefs({ block_uid, title });
109
- if (options.debug) {
110
- printDebug('Total matches', result.matches.length);
101
+ // Determine identifiers
102
+ let identifiers = [];
103
+ if (identifier && identifier !== '-') {
104
+ identifiers = [identifier];
111
105
  }
112
- // Apply limit
113
- const limitedMatches = result.matches.slice(0, limit);
114
- // Format output
115
- if (options.json) {
116
- const jsonOutput = limitedMatches.map(m => ({
117
- uid: m.block_uid,
118
- content: m.content,
119
- page: m.page_title
120
- }));
121
- console.log(JSON.stringify(jsonOutput, null, 2));
106
+ else {
107
+ if (process.stdin.isTTY && identifier !== '-') {
108
+ exitWithError('Identifier is required. Use: roam refs <title> or pipe identifiers via stdin');
109
+ }
110
+ const input = await readStdin();
111
+ if (input) {
112
+ identifiers = input.split('\n').map(t => t.trim()).filter(Boolean);
113
+ }
122
114
  }
123
- else if (options.raw) {
124
- console.log(formatRaw(limitedMatches));
115
+ if (identifiers.length === 0) {
116
+ exitWithError('No identifiers provided');
125
117
  }
126
- else {
127
- console.log(formatGrouped(limitedMatches));
118
+ const searchOps = new SearchOperations(graph);
119
+ // Helper to process a single identifier
120
+ const processIdentifier = async (id) => {
121
+ const { block_uid, title } = parseIdentifier(id);
122
+ if (options.debug) {
123
+ printDebug('Identifier', id);
124
+ printDebug('Parsed', { block_uid, title });
125
+ }
126
+ const result = await searchOps.searchBlockRefs({ block_uid, title });
127
+ const limitedMatches = result.matches.slice(0, limit);
128
+ if (options.json) {
129
+ return JSON.stringify(limitedMatches.map(m => ({
130
+ uid: m.block_uid,
131
+ content: m.content,
132
+ page: m.page_title
133
+ })));
134
+ }
135
+ else if (options.raw) {
136
+ return formatRaw(limitedMatches);
137
+ }
138
+ else {
139
+ return formatGrouped(limitedMatches);
140
+ }
141
+ };
142
+ // Execute
143
+ for (const id of identifiers) {
144
+ const output = await processIdentifier(id);
145
+ console.log(output);
146
+ if (identifiers.length > 1 && !options.json)
147
+ console.log('\n---\n');
128
148
  }
129
149
  }
130
150
  catch (error) {
@@ -7,6 +7,7 @@ import { BatchOperations } from '../../tools/operations/batch.js';
7
7
  import { parseMarkdown, generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
8
8
  import { printDebug, exitWithError } from '../utils/output.js';
9
9
  import { resolveGraph } from '../utils/graph.js';
10
+ import { readStdin } from '../utils/input.js';
10
11
  import { formatRoamDate } from '../../utils/helpers.js';
11
12
  import { q, createPage as roamCreatePage } from '@roam-research/roam-api-sdk';
12
13
  /**
@@ -27,14 +28,53 @@ function flattenNodes(nodes, baseLevel = 1) {
27
28
  return result;
28
29
  }
29
30
  /**
30
- * Read all input from stdin
31
+ * Infer hierarchy from heading levels when all blocks are at the same level.
32
+ * This handles prose-style markdown where headings (# ## ###) define structure
33
+ * without explicit indentation.
34
+ *
35
+ * Example: "# Title\n## Chapter\nContent" becomes:
36
+ * - Title (level 1, H1)
37
+ * - Chapter (level 2, H2)
38
+ * - Content (level 3)
31
39
  */
32
- async function readStdin() {
33
- const chunks = [];
34
- for await (const chunk of process.stdin) {
35
- chunks.push(chunk);
40
+ function adjustLevelsForHeadingHierarchy(blocks) {
41
+ if (blocks.length === 0)
42
+ return blocks;
43
+ // Only apply heading-based adjustment when:
44
+ // 1. All blocks are at the same level (no indentation-based hierarchy)
45
+ // 2. There are headings present
46
+ const allSameLevel = blocks.every(b => b.level === blocks[0].level);
47
+ const hasHeadings = blocks.some(b => b.heading);
48
+ if (!allSameLevel || !hasHeadings) {
49
+ // Indentation-based hierarchy exists, preserve it
50
+ return blocks;
36
51
  }
37
- return Buffer.concat(chunks).toString('utf-8');
52
+ const result = [];
53
+ // Track heading stack: each entry is { headingLevel: 1|2|3, adjustedLevel: number }
54
+ const headingStack = [];
55
+ for (const block of blocks) {
56
+ if (block.heading) {
57
+ // Pop headings of same or lower priority (higher h-number)
58
+ while (headingStack.length > 0 &&
59
+ headingStack[headingStack.length - 1].headingLevel >= block.heading) {
60
+ headingStack.pop();
61
+ }
62
+ // New heading level is one deeper than parent heading (or 1 if no parent)
63
+ const adjustedLevel = headingStack.length > 0
64
+ ? headingStack[headingStack.length - 1].adjustedLevel + 1
65
+ : 1;
66
+ headingStack.push({ headingLevel: block.heading, adjustedLevel });
67
+ result.push({ ...block, level: adjustedLevel });
68
+ }
69
+ else {
70
+ // Content: nest under current heading context
71
+ const adjustedLevel = headingStack.length > 0
72
+ ? headingStack[headingStack.length - 1].adjustedLevel + 1
73
+ : 1;
74
+ result.push({ ...block, level: adjustedLevel });
75
+ }
76
+ }
77
+ return result;
38
78
  }
39
79
  /**
40
80
  * Check if a string looks like a Roam block UID (9 alphanumeric chars with _ or -)
@@ -65,6 +105,8 @@ async function findOrCreatePage(graph, title) {
65
105
  action: 'create-page',
66
106
  page: { title }
67
107
  });
108
+ // Small delay for new page to be fully available as parent in Roam
109
+ await new Promise(resolve => setTimeout(resolve, 400));
68
110
  const results = await q(graph, findQuery, [title]);
69
111
  if (!results || results.length === 0) {
70
112
  throw new Error(`Could not find created page: ${title}`);
@@ -146,6 +188,7 @@ export function createSaveCommand() {
146
188
  .option('-c, --categories <tags>', 'Comma-separated tags appended to first block')
147
189
  .option('-t, --todo [text]', 'Add TODO item(s) to daily page. Accepts inline text or stdin')
148
190
  .option('--json', 'Force JSON array format: [{text, level, heading?}, ...]')
191
+ .option('--flatten', 'Disable heading hierarchy inference (all blocks at root level)')
149
192
  .option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
150
193
  .option('--write-key <key>', 'Write confirmation key (non-default graphs)')
151
194
  .option('--debug', 'Show debug information')
@@ -169,6 +212,11 @@ Examples:
169
212
  roam save notes.md --title "My Notes" --update # Smart update (preserves UIDs)
170
213
  cat data.json | roam save --json # Pipe JSON blocks
171
214
 
215
+ # Stdin operations
216
+ echo "Task from CLI" | roam save --todo # Pipe to TODO
217
+ cat note.md | roam save --title "From Pipe" # Pipe file content to new page
218
+ echo "Quick capture" | roam save -p "Inbox" # Pipe to specific page
219
+
172
220
  # Combine options
173
221
  roam save -p "Work" --parent "## Today" "Done with task" -c "wins"
174
222
 
@@ -219,7 +267,7 @@ JSON format (--json):
219
267
  let content;
220
268
  let isFile = false;
221
269
  let sourceFilename;
222
- if (input) {
270
+ if (input && input !== '-') {
223
271
  // Check if input is a file path that exists
224
272
  if (existsSync(input)) {
225
273
  isFile = true;
@@ -237,8 +285,8 @@ JSON format (--json):
237
285
  }
238
286
  }
239
287
  else {
240
- // Read from stdin
241
- if (process.stdin.isTTY) {
288
+ // Read from stdin (or if input is explicit '-')
289
+ if (process.stdin.isTTY && input !== '-') {
242
290
  exitWithError('No input. Use: roam save "text", roam save <file>, or pipe content');
243
291
  }
244
292
  content = await readStdin();
@@ -262,7 +310,9 @@ JSON format (--json):
262
310
  else if (isFile || content.includes('\n')) {
263
311
  // Multi-line content: parse as markdown
264
312
  const nodes = parseMarkdown(content);
265
- contentBlocks = flattenNodes(nodes);
313
+ const flattened = flattenNodes(nodes);
314
+ // Apply heading hierarchy unless --flatten is specified
315
+ contentBlocks = options.flatten ? flattened : adjustLevelsForHeadingHierarchy(flattened);
266
316
  }
267
317
  else {
268
318
  // Single line text: detect heading syntax and strip hashes
@@ -302,6 +352,7 @@ JSON format (--json):
302
352
  printDebug('Input', input || 'stdin');
303
353
  printDebug('Is file', isFile);
304
354
  printDebug('Is JSON', isJson);
355
+ printDebug('Flatten mode', options.flatten || false);
305
356
  printDebug('Graph', options.graph || 'default');
306
357
  printDebug('Content blocks', contentBlocks.length);
307
358
  printDebug('Parent UID', parentUid || 'none');
@@ -2,6 +2,7 @@ import { Command } from 'commander';
2
2
  import { SearchOperations } from '../../tools/operations/search/index.js';
3
3
  import { formatSearchResults, printDebug, exitWithError } from '../utils/output.js';
4
4
  import { resolveGraph } from '../utils/graph.js';
5
+ import { readStdin } from '../utils/input.js';
5
6
  /**
6
7
  * Normalize a tag by stripping #, [[, ]] wrappers
7
8
  */
@@ -20,7 +21,7 @@ function contentHasTag(content, tag) {
20
21
  export function createSearchCommand() {
21
22
  return new Command('search')
22
23
  .description('Search blocks by text, tags, Datalog queries, or within specific pages')
23
- .argument('[terms...]', 'Search terms (multiple terms use AND logic)')
24
+ .argument('[terms...]', 'Search terms (multiple terms use AND logic). Reads from stdin if omitted.')
24
25
  .option('--tag <tag>', 'Filter by tag (repeatable, comma-separated). Default: AND logic', (val, prev) => {
25
26
  // Support both comma-separated and multiple flags
26
27
  const tags = val.split(',').map(t => t.trim()).filter(Boolean);
@@ -41,43 +42,36 @@ export function createSearchCommand() {
41
42
  .option('--inputs <json>', 'JSON array of inputs for Datalog query')
42
43
  .option('--regex <pattern>', 'Client-side regex filter on Datalog results')
43
44
  .option('--regex-flags <flags>', 'Regex flags (e.g., "i" for case-insensitive)')
45
+ .option('--namespace <prefix>', 'Search for pages by namespace prefix (e.g., "Convention" finds "Convention/*")')
44
46
  .addHelpText('after', `
45
47
  Examples:
46
48
  # Text search
47
49
  roam search "meeting notes" # Find blocks containing text
48
50
  roam search api integration # Multiple terms (AND logic)
49
- roam search "bug fix" -i # Case-insensitive search
51
+
52
+ # Namespace search (find pages by title prefix)
53
+ roam search --namespace Convention # Find all Convention/* pages
54
+ roam search --namespace "Convention/" # Same (trailing slash optional)
55
+
56
+ # Stdin search
57
+ echo "urgent project" | roam search # Pipe terms
58
+ roam get today | roam search TODO # Search within output
50
59
 
51
60
  # Tag search
52
61
  roam search --tag TODO # All blocks with #TODO
53
62
  roam search --tag "[[Project Alpha]]" # Blocks with page reference
54
- roam search --tag work --page "January 3rd, 2026" # Tag on specific page
55
-
56
- # Multiple tags
57
- roam search --tag TODO --tag urgent # Blocks with BOTH tags (AND)
58
- roam search --tag "TODO,urgent,blocked" # Comma-separated (AND)
59
- roam search --tag TODO --tag urgent --any # Blocks with ANY tag (OR)
60
-
61
- # Exclude tags
62
- roam search --tag TODO --negtag done # TODOs excluding #done
63
- roam search --tag TODO --negtag "someday,maybe" # Exclude multiple
64
63
 
65
- # Combined filters
66
- roam search urgent --tag TODO # Text + tag filter
67
- roam search "review" --page "Work" # Search within page
64
+ # Datalog queries (advanced)
65
+ roam search -q '[:find ?uid ?s :where [?b :block/uid ?uid] [?b :block/string ?s]]' --regex "meeting"
68
66
 
69
- # Output options
70
- roam search "design" -n 50 # Limit to 50 results
71
- roam search "api" --json # JSON output
67
+ # Chaining with jq
68
+ roam search TODO --json | jq '.[].block_uid'
72
69
 
73
- # Datalog queries (advanced)
74
- roam search -q '[:find ?title :where [?e :node/title ?title]]'
75
- roam search -q '[:find ?s :in $ ?term :where [?b :block/string ?s] [(clojure.string/includes? ?s ?term)]]' --inputs '["TODO"]'
76
- roam search -q '[:find ?uid ?s :where [?b :block/uid ?uid] [?b :block/string ?s]]' --regex "meeting" --regex-flags "i"
70
+ Output format:
71
+ Markdown: Flat results with UIDs and content (no hierarchy).
72
+ JSON: [{ block_uid, content, page_title }] or [{ page_uid, page_title }] for namespace
77
73
 
78
- Datalog tips:
79
- Common attributes: :node/title, :block/string, :block/uid, :block/page, :block/children
80
- Predicates: clojure.string/includes?, clojure.string/starts-with?, <, >, =
74
+ Note: For hierarchical output with children, use 'roam get --tag/--text' instead.
81
75
  `)
82
76
  .action(async (terms, options) => {
83
77
  try {
@@ -87,14 +81,52 @@ Datalog tips:
87
81
  json: options.json,
88
82
  debug: options.debug
89
83
  };
84
+ let searchTerms = terms;
85
+ // If no terms provided as args, try stdin
86
+ if (searchTerms.length === 0 && !process.stdin.isTTY && !options.query && (options.tag?.length === 0)) {
87
+ const input = await readStdin();
88
+ if (input) {
89
+ searchTerms = input.trim().split(/\s+/);
90
+ }
91
+ }
90
92
  if (options.debug) {
91
- printDebug('Search terms', terms);
93
+ printDebug('Search terms', searchTerms);
92
94
  printDebug('Graph', options.graph || 'default');
93
95
  printDebug('Options', options);
94
96
  }
95
97
  const searchOps = new SearchOperations(graph);
98
+ // Namespace search mode (search page titles by prefix)
99
+ if (options.namespace) {
100
+ const result = await searchOps.searchByText({
101
+ text: options.namespace,
102
+ scope: 'page_titles'
103
+ });
104
+ if (!result.success) {
105
+ exitWithError(result.message || 'Namespace search failed');
106
+ }
107
+ let matches = result.matches.slice(0, limit);
108
+ if (options.json) {
109
+ // For JSON output, return page_uid and page_title
110
+ const jsonMatches = matches.map(m => ({
111
+ page_uid: m.block_uid,
112
+ page_title: m.page_title
113
+ }));
114
+ console.log(JSON.stringify(jsonMatches, null, 2));
115
+ }
116
+ else {
117
+ if (matches.length === 0) {
118
+ console.log('No pages found.');
119
+ }
120
+ else {
121
+ console.log(`Found ${result.matches.length} page(s)${result.matches.length > limit ? ` (showing first ${limit})` : ''}:\n`);
122
+ for (const match of matches) {
123
+ console.log(`- ${match.page_title} (${match.block_uid})`);
124
+ }
125
+ }
126
+ }
127
+ return;
128
+ }
96
129
  // Datalog query mode (bypasses other search options)
97
- // See for query construction - Roam_Research_Datalog_Cheatsheet.md
98
130
  if (options.query) {
99
131
  // Parse inputs if provided
100
132
  let inputs;
@@ -109,11 +141,6 @@ Datalog tips:
109
141
  exitWithError('Invalid JSON in --inputs');
110
142
  }
111
143
  }
112
- if (options.debug) {
113
- printDebug('Datalog query', options.query);
114
- printDebug('Inputs', inputs || 'none');
115
- printDebug('Regex filter', options.regex || 'none');
116
- }
117
144
  const result = await searchOps.executeDatomicQuery({
118
145
  query: options.query,
119
146
  inputs,
@@ -126,7 +153,6 @@ Datalog tips:
126
153
  // Apply limit and format output
127
154
  const limitedMatches = result.matches.slice(0, limit);
128
155
  if (options.json) {
129
- // For JSON output, parse the content back to objects
130
156
  const parsed = limitedMatches.map(m => {
131
157
  try {
132
158
  return JSON.parse(m.content);
@@ -138,7 +164,6 @@ Datalog tips:
138
164
  console.log(JSON.stringify(parsed, null, 2));
139
165
  }
140
166
  else {
141
- // For text output, show raw results
142
167
  if (limitedMatches.length === 0) {
143
168
  console.log('No results found.');
144
169
  }
@@ -153,34 +178,22 @@ Datalog tips:
153
178
  }
154
179
  // Determine search type based on options
155
180
  const tags = options.tag || [];
156
- if (tags.length > 0 && terms.length === 0) {
181
+ if (tags.length > 0 && searchTerms.length === 0) {
157
182
  // Tag-only search
158
183
  const normalizedTags = tags.map(normalizeTag);
159
184
  const useOrLogic = options.any || false;
160
- if (options.debug) {
161
- printDebug('Tag search', {
162
- tags: normalizedTags,
163
- logic: useOrLogic ? 'OR' : 'AND',
164
- page: options.page
165
- });
166
- }
167
- // Search for first tag, then filter by additional tags
168
185
  const result = await searchOps.searchForTag(normalizedTags[0], options.page);
169
186
  let matches = result.matches;
170
- // Apply multi-tag filter if more than one tag
171
187
  if (normalizedTags.length > 1) {
172
188
  matches = matches.filter(m => {
173
189
  if (useOrLogic) {
174
- // OR: has at least one tag
175
190
  return normalizedTags.some(tag => contentHasTag(m.content, tag));
176
191
  }
177
192
  else {
178
- // AND: has all tags
179
193
  return normalizedTags.every(tag => contentHasTag(m.content, tag));
180
194
  }
181
195
  });
182
196
  }
183
- // Apply negtag filter (exclude blocks with any of these tags)
184
197
  const negTags = options.negtag || [];
185
198
  if (negTags.length > 0) {
186
199
  const normalizedNegTags = negTags.map(normalizeTag);
@@ -189,24 +202,18 @@ Datalog tips:
189
202
  const limitedMatches = matches.slice(0, limit);
190
203
  console.log(formatSearchResults(limitedMatches, outputOptions));
191
204
  }
192
- else if (terms.length > 0) {
193
- // Text search (with optional tag filter)
194
- const searchText = terms.join(' ');
195
- if (options.debug) {
196
- printDebug('Text search', { text: searchText, page: options.page, tag: options.tag });
197
- }
205
+ else if (searchTerms.length > 0) {
206
+ // Text search
207
+ const searchText = searchTerms.join(' ');
198
208
  const result = await searchOps.searchByText({
199
209
  text: searchText,
200
210
  page_title_uid: options.page
201
211
  });
202
- // Apply client-side filters
203
212
  let matches = result.matches;
204
- // Case-insensitive filter if requested
205
213
  if (options.caseInsensitive) {
206
214
  const lowerSearchText = searchText.toLowerCase();
207
215
  matches = matches.filter(m => m.content.toLowerCase().includes(lowerSearchText));
208
216
  }
209
- // Tag filter if provided
210
217
  if (tags.length > 0) {
211
218
  const normalizedTags = tags.map(normalizeTag);
212
219
  const useOrLogic = options.any || false;
@@ -219,13 +226,11 @@ Datalog tips:
219
226
  }
220
227
  });
221
228
  }
222
- // Negtag filter (exclude blocks with any of these tags)
223
229
  const negTags = options.negtag || [];
224
230
  if (negTags.length > 0) {
225
231
  const normalizedNegTags = negTags.map(normalizeTag);
226
232
  matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag)));
227
233
  }
228
- // Apply limit
229
234
  console.log(formatSearchResults(matches.slice(0, limit), outputOptions));
230
235
  }
231
236
  else {
@@ -25,6 +25,8 @@ Examples:
25
25
 
26
26
  # JSON output for scripting
27
27
  roam status --json
28
+
29
+ JSON output fields: { version, graphs: [{ name, default, protected, connected?, error? }] }
28
30
  `)
29
31
  .action(async (options) => {
30
32
  try {
@@ -34,15 +36,12 @@ Examples:
34
36
  for (const key of graphKeys) {
35
37
  const config = registry.getConfig(key);
36
38
  const isDefault = key === registry.defaultKey;
37
- const isProtected = !!config.write_key;
39
+ const isProtected = !!config.protected;
38
40
  const status = {
39
41
  name: key,
40
42
  default: isDefault,
41
43
  protected: isProtected,
42
44
  };
43
- if (isProtected) {
44
- status.writeKey = config.write_key;
45
- }
46
45
  if (options.ping) {
47
46
  try {
48
47
  const graph = registry.getGraph(key);
@@ -1,8 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import { BatchOperations } from '../../tools/operations/batch.js';
3
+ import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
3
4
  import { parseMarkdownHeadingLevel } from '../../markdown-utils.js';
4
5
  import { printDebug, exitWithError } from '../utils/output.js';
5
6
  import { resolveGraph } from '../utils/graph.js';
7
+ import { readStdin } from '../utils/input.js';
6
8
  // Patterns for TODO/DONE markers (both {{TODO}} and {{[[TODO]]}} formats)
7
9
  const TODO_PATTERN = /\{\{(\[\[)?TODO(\]\])?\}\}\s*/g;
8
10
  const DONE_PATTERN = /\{\{(\[\[)?DONE(\]\])?\}\}\s*/g;
@@ -43,7 +45,7 @@ export function createUpdateCommand() {
43
45
  return new Command('update')
44
46
  .description('Update block content, heading, open/closed state, or TODO/DONE status')
45
47
  .argument('<uid>', 'Block UID to update (accepts ((uid)) wrapper)')
46
- .argument('<content>', 'New content. Use # prefix for heading: "# Title" sets H1')
48
+ .argument('[content]', 'New content. Use # prefix for heading: "# Title" sets H1. Reads from stdin if "-" or omitted (when piped).')
47
49
  .option('-H, --heading <level>', 'Set heading level (1-3), or 0 to remove')
48
50
  .option('-o, --open', 'Expand block (show children)')
49
51
  .option('-c, --closed', 'Collapse block (hide children)')
@@ -72,30 +74,82 @@ Examples:
72
74
  roam update abc123def "Task" -T # Set as TODO
73
75
  roam update abc123def "Task" -D # Mark as DONE
74
76
  roam update abc123def "Task" --clear-status # Remove status marker
77
+
78
+ # Stdin / Partial Updates
79
+ echo "New text" | roam update abc123def # Pipe content
80
+ roam update abc123def -T # Add TODO (fetches existing text)
81
+ roam update abc123def -o # Expand block (keeps text)
75
82
  `)
76
83
  .action(async (uid, content, options) => {
77
84
  try {
78
85
  // Strip (( )) wrapper if present
79
86
  const blockUid = uid.replace(/^\(\(|\)\)$/g, '');
80
- // Detect heading from content unless explicitly set
81
- let finalContent = content;
87
+ const graph = resolveGraph(options, true); // This is a write operation
88
+ let finalContent;
89
+ // 1. Determine new content from args or stdin
90
+ if (content && content !== '-') {
91
+ finalContent = content;
92
+ }
93
+ else if (content === '-' || (!content && !process.stdin.isTTY)) {
94
+ finalContent = await readStdin();
95
+ finalContent = finalContent.trim();
96
+ }
97
+ // 2. Identify if we need to fetch existing content
98
+ const isStatusUpdate = options.todo || options.done || options.clearStatus;
99
+ const isHeadingUpdate = options.heading !== undefined;
100
+ const isStateUpdate = options.open || options.closed;
101
+ if (finalContent === undefined) {
102
+ if (isStatusUpdate) {
103
+ // Must fetch to apply status safely
104
+ const blockRetrieval = new BlockRetrievalOperations(graph);
105
+ const block = await blockRetrieval.fetchBlockWithChildren(blockUid, 0);
106
+ if (!block) {
107
+ exitWithError(`Block ${blockUid} not found`);
108
+ }
109
+ finalContent = block.string;
110
+ }
111
+ else if (!isHeadingUpdate && !isStateUpdate) {
112
+ exitWithError('No content or update options provided.');
113
+ }
114
+ }
115
+ // 3. Process content if we have it
82
116
  let headingLevel;
83
- if (options.heading !== undefined) {
84
- // Explicit heading option takes precedence
85
- const level = parseInt(options.heading, 10);
86
- if (level >= 0 && level <= 3) {
87
- headingLevel = level === 0 ? undefined : level;
88
- // Still strip # prefix if present for consistency
89
- const { content: stripped } = parseMarkdownHeadingLevel(content);
90
- finalContent = stripped;
117
+ if (finalContent !== undefined) {
118
+ // Handle explicit heading option
119
+ if (options.heading !== undefined) {
120
+ const level = parseInt(options.heading, 10);
121
+ if (level >= 0 && level <= 3) {
122
+ headingLevel = level === 0 ? undefined : level;
123
+ const { content: stripped } = parseMarkdownHeadingLevel(finalContent);
124
+ finalContent = stripped;
125
+ }
126
+ }
127
+ else {
128
+ // Auto-detect heading from content
129
+ const { heading_level, content: stripped } = parseMarkdownHeadingLevel(finalContent);
130
+ if (heading_level > 0) {
131
+ headingLevel = heading_level;
132
+ finalContent = stripped;
133
+ }
134
+ }
135
+ // Handle TODO/DONE status
136
+ if (options.clearStatus) {
137
+ finalContent = clearStatus(finalContent);
138
+ }
139
+ else if (options.todo) {
140
+ finalContent = applyStatus(finalContent, 'TODO');
141
+ }
142
+ else if (options.done) {
143
+ finalContent = applyStatus(finalContent, 'DONE');
91
144
  }
92
145
  }
93
146
  else {
94
- // Auto-detect heading from content
95
- const { heading_level, content: stripped } = parseMarkdownHeadingLevel(content);
96
- if (heading_level > 0) {
97
- headingLevel = heading_level;
98
- finalContent = stripped;
147
+ // No content update, just metadata
148
+ if (options.heading !== undefined) {
149
+ const level = parseInt(options.heading, 10);
150
+ if (level >= 0 && level <= 3) {
151
+ headingLevel = level === 0 ? undefined : level;
152
+ }
99
153
  }
100
154
  }
101
155
  // Handle open/closed state
@@ -106,25 +160,14 @@ Examples:
106
160
  else if (options.closed) {
107
161
  openState = false;
108
162
  }
109
- // Handle TODO/DONE status
110
- if (options.clearStatus) {
111
- finalContent = clearStatus(finalContent);
112
- }
113
- else if (options.todo) {
114
- finalContent = applyStatus(finalContent, 'TODO');
115
- }
116
- else if (options.done) {
117
- finalContent = applyStatus(finalContent, 'DONE');
118
- }
119
163
  if (options.debug) {
120
164
  printDebug('Block UID', blockUid);
121
165
  printDebug('Graph', options.graph || 'default');
122
- printDebug('Content', finalContent);
166
+ printDebug('Content', finalContent !== undefined ? finalContent : '(no change)');
123
167
  printDebug('Heading level', headingLevel ?? 'none');
124
168
  printDebug('Open state', openState ?? 'unchanged');
125
169
  printDebug('Status', options.todo ? 'TODO' : options.done ? 'DONE' : options.clearStatus ? 'cleared' : 'unchanged');
126
170
  }
127
- const graph = resolveGraph(options, true); // This is a write operation
128
171
  const batchOps = new BatchOperations(graph);
129
172
  const result = await batchOps.processBatch([{
130
173
  action: 'update-block',