roam-research-mcp 1.4.0 → 2.4.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 (34) hide show
  1. package/README.md +360 -31
  2. package/build/Roam_Markdown_Cheatsheet.md +30 -12
  3. package/build/cli/batch/resolver.js +138 -0
  4. package/build/cli/batch/translator.js +363 -0
  5. package/build/cli/batch/types.js +4 -0
  6. package/build/cli/commands/batch.js +352 -0
  7. package/build/cli/commands/get.js +161 -0
  8. package/build/cli/commands/refs.js +135 -0
  9. package/build/cli/commands/rename.js +58 -0
  10. package/build/cli/commands/save.js +498 -0
  11. package/build/cli/commands/search.js +240 -0
  12. package/build/cli/commands/status.js +91 -0
  13. package/build/cli/commands/update.js +151 -0
  14. package/build/cli/roam.js +35 -0
  15. package/build/cli/utils/graph.js +56 -0
  16. package/build/cli/utils/output.js +122 -0
  17. package/build/config/environment.js +70 -34
  18. package/build/config/graph-registry.js +221 -0
  19. package/build/config/graph-registry.test.js +30 -0
  20. package/build/search/block-ref-search.js +34 -7
  21. package/build/search/status-search.js +5 -4
  22. package/build/server/roam-server.js +98 -53
  23. package/build/shared/validation.js +10 -5
  24. package/build/tools/helpers/refs.js +50 -31
  25. package/build/tools/operations/blocks.js +38 -1
  26. package/build/tools/operations/memory.js +51 -5
  27. package/build/tools/operations/pages.js +186 -111
  28. package/build/tools/operations/search/index.js +5 -1
  29. package/build/tools/operations/todos.js +1 -1
  30. package/build/tools/schemas.js +121 -41
  31. package/build/tools/tool-handlers.js +9 -2
  32. package/build/utils/helpers.js +22 -0
  33. package/package.json +11 -7
  34. package/build/cli/import-markdown.js +0 -98
@@ -0,0 +1,352 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'fs';
3
+ import { BatchOperations } from '../../tools/operations/batch.js';
4
+ import { PageOperations } from '../../tools/operations/pages.js';
5
+ import { printDebug, exitWithError } from '../utils/output.js';
6
+ import { resolveGraph } from '../utils/graph.js';
7
+ import { collectPageTitles, resolveAllPages, resolveDailyPageUid, needsDailyPage, createResolutionContext, getDailyPageTitle } from '../batch/resolver.js';
8
+ import { translateAllCommands } from '../batch/translator.js';
9
+ /** Read all input from stdin */
10
+ async function readStdin() {
11
+ const chunks = [];
12
+ for await (const chunk of process.stdin) {
13
+ chunks.push(chunk);
14
+ }
15
+ return Buffer.concat(chunks).toString('utf-8');
16
+ }
17
+ // Required params per command type
18
+ const REQUIRED_PARAMS = {
19
+ todo: ['text'],
20
+ create: ['parent', 'text'],
21
+ update: ['uid'],
22
+ delete: ['uid'],
23
+ move: ['uid', 'parent'],
24
+ page: ['title'],
25
+ outline: ['parent', 'items'],
26
+ table: ['parent', 'headers', 'rows'],
27
+ remember: ['text'],
28
+ codeblock: ['parent', 'code']
29
+ };
30
+ const VALID_COMMAND_TYPES = Object.keys(REQUIRED_PARAMS);
31
+ /**
32
+ * Validate command structure and required params
33
+ */
34
+ function validateCommands(commands) {
35
+ const validated = [];
36
+ for (let i = 0; i < commands.length; i++) {
37
+ const cmd = commands[i];
38
+ if (!cmd || typeof cmd !== 'object') {
39
+ throw new Error(`[${i}] Command must be an object`);
40
+ }
41
+ if (!cmd.command || typeof cmd.command !== 'string') {
42
+ throw new Error(`[${i}] Missing 'command' field`);
43
+ }
44
+ const cmdType = cmd.command;
45
+ if (!VALID_COMMAND_TYPES.includes(cmdType)) {
46
+ throw new Error(`[${i}] Unknown command type '${cmdType}'. Valid: ${VALID_COMMAND_TYPES.join(', ')}`);
47
+ }
48
+ if (!cmd.params || typeof cmd.params !== 'object') {
49
+ throw new Error(`[${i}] Missing 'params' field`);
50
+ }
51
+ // Check required params
52
+ const params = cmd.params;
53
+ const required = REQUIRED_PARAMS[cmdType];
54
+ for (const param of required) {
55
+ if (params[param] === undefined) {
56
+ throw new Error(`[${i}] ${cmdType}: missing required param '${param}'`);
57
+ }
58
+ }
59
+ validated.push(cmd);
60
+ }
61
+ return validated;
62
+ }
63
+ /**
64
+ * Validate placeholder references are defined before use
65
+ * Returns list of errors (empty = valid)
66
+ */
67
+ function validatePlaceholders(commands) {
68
+ const errors = [];
69
+ const definedPlaceholders = new Set();
70
+ for (let i = 0; i < commands.length; i++) {
71
+ const cmd = commands[i];
72
+ const params = cmd.params;
73
+ // Collect placeholder definitions from 'as' params
74
+ if (typeof params.as === 'string') {
75
+ definedPlaceholders.add(params.as);
76
+ }
77
+ // Check placeholder references in 'parent' param
78
+ if (typeof params.parent === 'string') {
79
+ const match = params.parent.match(/^\{\{(\w+)\}\}$/);
80
+ if (match) {
81
+ const refName = match[1];
82
+ if (!definedPlaceholders.has(refName)) {
83
+ errors.push(`[${i}] ${cmd.command}: placeholder "{{${refName}}}" used before definition`);
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return errors;
89
+ }
90
+ /**
91
+ * Output partial results when a failure occurs mid-batch
92
+ * Helps user know what was created and may need manual cleanup
93
+ */
94
+ function outputPartialResults(pageResults, failedPage, batchError) {
95
+ const output = {
96
+ success: false,
97
+ partial: true,
98
+ pages_created: pageResults.length,
99
+ ...(failedPage && { failed_at: `page: ${failedPage}` }),
100
+ ...(batchError && { failed_at: `batch: ${batchError}` })
101
+ };
102
+ if (pageResults.length > 0) {
103
+ output.created_pages = pageResults.map(p => ({
104
+ title: p.title,
105
+ uid: p.uid
106
+ }));
107
+ output.cleanup_hint = 'Pages listed above were created before failure. Delete manually if needed.';
108
+ }
109
+ console.error(JSON.stringify(output, null, 2));
110
+ process.exit(1);
111
+ }
112
+ /** Format action for dry-run display */
113
+ function formatAction(action, index) {
114
+ const lines = [` ${index + 1}. ${action.action}`];
115
+ if (action.string) {
116
+ const text = String(action.string);
117
+ const preview = text.length > 60 ? text.slice(0, 60) + '...' : text;
118
+ lines.push(` text: "${preview}"`);
119
+ }
120
+ if (action.location) {
121
+ const loc = action.location;
122
+ lines.push(` parent: ${loc['parent-uid']}`);
123
+ }
124
+ if (action.uid) {
125
+ lines.push(` uid: ${action.uid}`);
126
+ }
127
+ return lines.join('\n');
128
+ }
129
+ export function createBatchCommand() {
130
+ return new Command('batch')
131
+ .description('Execute multiple block operations efficiently in a single API call')
132
+ .argument('[file]', 'JSON file with commands (or pipe via stdin)')
133
+ .option('--debug', 'Show debug information')
134
+ .option('--dry-run', 'Validate and show planned actions without executing')
135
+ .option('--simulate', 'Validate structure offline (no API calls)')
136
+ .option('-g, --graph <name>', 'Target graph key (for multi-graph mode)')
137
+ .option('--write-key <key>', 'Write confirmation key (for non-default graphs)')
138
+ .addHelpText('after', `
139
+ Examples:
140
+ # From file
141
+ roam batch commands.json # Execute commands from file
142
+ roam batch commands.json --dry-run # Preview without executing (resolves pages)
143
+ roam batch commands.json --simulate # Validate offline (no API calls)
144
+
145
+ # From stdin
146
+ cat commands.json | roam batch # Pipe commands
147
+ echo '[{"command":"todo","params":{"text":"Task 1"}}]' | roam batch
148
+
149
+ Command schemas:
150
+ todo: {text}
151
+ create: {parent, text, as?, heading?, order?}
152
+ update: {uid, text?, heading?, open?}
153
+ delete: {uid}
154
+ move: {uid, parent, order?}
155
+ page: {title, as?, content?: [{text, level, heading?}...]}
156
+ outline: {parent, items: [string...]}
157
+ table: {parent, headers: [string...], rows: [{label, cells: [string...]}...]}
158
+ remember: {text, categories?: [string...]}
159
+ codeblock: {parent, code, language?}
160
+
161
+ Parent accepts: block UID, "daily", page title, or {{placeholder}}
162
+
163
+ Example:
164
+ [
165
+ {"command": "page", "params": {"title": "Project X", "as": "proj"}},
166
+ {"command": "create", "params": {"parent": "{{proj}}", "text": "# Overview", "as": "overview"}},
167
+ {"command": "outline", "params": {"parent": "{{overview}}", "items": ["Goal 1", "Goal 2"]}},
168
+ {"command": "todo", "params": {"text": "Review project"}}
169
+ ]
170
+ `)
171
+ .action(async (file, options) => {
172
+ try {
173
+ // Read input
174
+ let rawInput;
175
+ if (file) {
176
+ try {
177
+ rawInput = readFileSync(file, 'utf-8');
178
+ }
179
+ catch {
180
+ exitWithError(`Could not read file: ${file}`);
181
+ }
182
+ }
183
+ else if (process.stdin.isTTY) {
184
+ exitWithError('No file specified and no input piped. Use: roam batch commands.json or cat commands.json | roam batch');
185
+ }
186
+ else {
187
+ rawInput = await readStdin();
188
+ }
189
+ // Parse JSON
190
+ let parsed;
191
+ try {
192
+ parsed = JSON.parse(rawInput);
193
+ }
194
+ catch (err) {
195
+ exitWithError(`Invalid JSON: ${err instanceof SyntaxError ? err.message : 'parse error'}`);
196
+ }
197
+ if (!Array.isArray(parsed)) {
198
+ exitWithError('Input must be a JSON array of commands');
199
+ }
200
+ if (parsed.length === 0) {
201
+ console.log('No commands to execute');
202
+ return;
203
+ }
204
+ // Validate and get typed commands
205
+ const commands = validateCommands(parsed);
206
+ // Upfront validation: check placeholder references
207
+ const placeholderErrors = validatePlaceholders(commands);
208
+ if (placeholderErrors.length > 0) {
209
+ exitWithError(`Placeholder validation failed:\n ${placeholderErrors.join('\n ')}`);
210
+ }
211
+ if (options.debug) {
212
+ printDebug('Commands', commands.length);
213
+ printDebug('Graph', options.graph || 'default');
214
+ printDebug('Mode', options.simulate ? 'simulate' : options.dryRun ? 'dry-run' : 'execute');
215
+ }
216
+ // Simulate mode: validate structure without connecting to Roam
217
+ if (options.simulate) {
218
+ const context = createResolutionContext();
219
+ const { actions, pageCommands } = translateAllCommands(commands, context);
220
+ console.log('\n[SIMULATE] Validation passed\n');
221
+ console.log(`Commands: ${commands.length}`);
222
+ console.log(` Pages to create: ${pageCommands.length}`);
223
+ console.log(` Batch actions: ${actions.length}`);
224
+ if (pageCommands.length > 0) {
225
+ console.log('\nPage creations:');
226
+ for (const pc of pageCommands) {
227
+ const as = pc.params.as ? ` → {{${pc.params.as}}}` : '';
228
+ console.log(` - "${pc.params.title}"${as}`);
229
+ }
230
+ }
231
+ console.log('\nNo API calls made. Use --dry-run to resolve page UIDs.');
232
+ return;
233
+ }
234
+ const graph = resolveGraph(options, true);
235
+ // Phase 1: Collect and resolve page titles
236
+ const context = createResolutionContext();
237
+ const pageTitles = collectPageTitles(commands);
238
+ if (pageTitles.size > 0) {
239
+ if (options.debug) {
240
+ printDebug('Pages to resolve', Array.from(pageTitles));
241
+ }
242
+ const resolved = await resolveAllPages(graph, pageTitles);
243
+ for (const [title, uid] of resolved) {
244
+ context.pageUids.set(title, uid);
245
+ }
246
+ // Check for unresolved pages
247
+ const unresolved = Array.from(pageTitles).filter(t => !context.pageUids.has(t));
248
+ if (unresolved.length > 0) {
249
+ exitWithError(`Page(s) not found: ${unresolved.map(t => `"${t}"`).join(', ')}`);
250
+ }
251
+ if (options.debug) {
252
+ printDebug('Resolved pages', Object.fromEntries(context.pageUids));
253
+ }
254
+ }
255
+ // Resolve daily page if needed
256
+ if (needsDailyPage(commands)) {
257
+ const dailyUid = await resolveDailyPageUid(graph);
258
+ if (!dailyUid) {
259
+ exitWithError(`Daily page not found: "${getDailyPageTitle()}"`);
260
+ }
261
+ context.dailyPageUid = dailyUid;
262
+ context.pageUids.set(getDailyPageTitle(), dailyUid);
263
+ if (options.debug) {
264
+ printDebug('Daily page UID', dailyUid);
265
+ }
266
+ }
267
+ // Phase 2: Translate commands to batch actions
268
+ const { actions, pageCommands } = translateAllCommands(commands, context);
269
+ if (options.debug) {
270
+ printDebug('Batch actions', actions.length);
271
+ printDebug('Page commands', pageCommands.length);
272
+ }
273
+ // Dry run: show actions and exit
274
+ if (options.dryRun) {
275
+ console.log('\n[DRY RUN] Planned actions:\n');
276
+ if (pageCommands.length > 0) {
277
+ console.log('Page creations:');
278
+ for (const pc of pageCommands) {
279
+ const as = pc.params.as ? ` (as: {{${pc.params.as}}})` : '';
280
+ console.log(` - "${pc.params.title}"${as}`);
281
+ }
282
+ console.log('');
283
+ }
284
+ if (actions.length > 0) {
285
+ console.log('Batch actions:');
286
+ console.log(actions.map((a, i) => formatAction(a, i)).join('\n'));
287
+ }
288
+ console.log(`\nTotal: ${pageCommands.length} page(s), ${actions.length} action(s)`);
289
+ return;
290
+ }
291
+ // Phase 3: Execute page commands (in parallel where possible)
292
+ const pageResults = [];
293
+ if (pageCommands.length > 0) {
294
+ const pageOps = new PageOperations(graph);
295
+ // Execute page creations - must be sequential if they reference each other
296
+ for (const pc of pageCommands) {
297
+ const result = await pageOps.createPage(pc.params.title, pc.params.content?.map(item => ({
298
+ text: item.text,
299
+ level: item.level,
300
+ heading: item.heading
301
+ })));
302
+ if (!result.success) {
303
+ // Report partial results before exiting
304
+ outputPartialResults(pageResults, pc.params.title);
305
+ }
306
+ pageResults.push({ title: pc.params.title, uid: result.uid });
307
+ if (pc.params.as) {
308
+ context.placeholders.set(pc.params.as, result.uid);
309
+ }
310
+ if (options.debug) {
311
+ printDebug(`Created "${pc.params.title}"`, result.uid);
312
+ }
313
+ }
314
+ }
315
+ // Phase 4: Execute batch actions
316
+ let batchResult = { success: true, actions_attempted: 0 };
317
+ if (actions.length > 0) {
318
+ const batchOps = new BatchOperations(graph);
319
+ batchResult = await batchOps.processBatch(actions);
320
+ if (!batchResult.success) {
321
+ const errorMsg = typeof batchResult.error === 'string'
322
+ ? batchResult.error
323
+ : batchResult.error?.message || 'Unknown error';
324
+ // Report partial results (pages created before batch failed)
325
+ outputPartialResults(pageResults, undefined, errorMsg);
326
+ }
327
+ }
328
+ // Build output
329
+ const uidMap = {};
330
+ for (const pr of pageResults) {
331
+ const pageCmd = pageCommands.find(pc => pc.params.title === pr.title);
332
+ if (pageCmd?.params.as) {
333
+ uidMap[pageCmd.params.as] = pr.uid;
334
+ }
335
+ }
336
+ if (batchResult.uid_map) {
337
+ Object.assign(uidMap, batchResult.uid_map);
338
+ }
339
+ const output = {
340
+ success: true,
341
+ pages_created: pageResults.length,
342
+ actions_executed: actions.length,
343
+ ...(Object.keys(uidMap).length > 0 && { uid_map: uidMap })
344
+ };
345
+ console.log(JSON.stringify(output, null, 2));
346
+ }
347
+ catch (error) {
348
+ const message = error instanceof Error ? error.message : String(error);
349
+ exitWithError(message);
350
+ }
351
+ });
352
+ }
@@ -0,0 +1,161 @@
1
+ import { Command } from 'commander';
2
+ import { PageOperations } from '../../tools/operations/pages.js';
3
+ import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
4
+ import { SearchOperations } from '../../tools/operations/search/index.js';
5
+ import { formatPageOutput, formatBlockOutput, formatTodoOutput, printDebug, exitWithError } from '../utils/output.js';
6
+ import { resolveGraph } from '../utils/graph.js';
7
+ import { resolveRefs } from '../../tools/helpers/refs.js';
8
+ import { resolveRelativeDate } from '../../utils/helpers.js';
9
+ // Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
10
+ const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
11
+ /**
12
+ * Recursively resolve block references in a RoamBlock tree
13
+ */
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
+ return {
18
+ ...block,
19
+ string: resolvedString,
20
+ children: resolvedChildren
21
+ };
22
+ }
23
+ /**
24
+ * Resolve refs in an array of blocks
25
+ */
26
+ async function resolveBlocksRefs(graph, blocks, maxDepth) {
27
+ return Promise.all(blocks.map(block => resolveBlockRefs(graph, block, maxDepth)));
28
+ }
29
+ export function createGetCommand() {
30
+ return new Command('get')
31
+ .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)')
33
+ .option('-j, --json', 'Output as JSON instead of markdown')
34
+ .option('-d, --depth <n>', 'Child levels to fetch (default: 4)', '4')
35
+ .option('-r, --refs [n]', 'Expand ((uid)) refs in output (default depth: 1, max: 4)')
36
+ .option('-f, --flat', 'Flatten hierarchy to single-level list')
37
+ .option('--todo', 'Fetch TODO items')
38
+ .option('--done', 'Fetch DONE items')
39
+ .option('-p, --page <ref>', 'Filter TODOs/DONEs by page title or UID')
40
+ .option('-i, --include <terms>', 'Include items matching these terms (comma-separated)')
41
+ .option('-e, --exclude <terms>', 'Exclude items matching these terms (comma-separated)')
42
+ .option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
43
+ .option('--debug', 'Show query metadata')
44
+ .addHelpText('after', `
45
+ Examples:
46
+ # Fetch pages
47
+ roam get "Project Notes" # Page by title
48
+ roam get today # Today's daily page
49
+ roam get yesterday # Yesterday's daily page
50
+ roam get tomorrow # Tomorrow's daily page
51
+
52
+ # Fetch blocks
53
+ roam get abc123def # Block by UID
54
+ roam get "((abc123def))" # UID with wrapper
55
+
56
+ # Output options
57
+ roam get "Page" -j # JSON output
58
+ roam get "Page" -f # Flat list (no hierarchy)
59
+ roam get abc123def -d 2 # Limit depth to 2 levels
60
+ roam get "Page" -r # Expand block refs (depth 1)
61
+ roam get "Page" -r 3 # Expand refs up to 3 levels deep
62
+
63
+ # TODO/DONE items (refs auto-expanded)
64
+ roam get --todo # All TODOs across graph
65
+ roam get --done # All completed items
66
+ 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
69
+ `)
70
+ .action(async (target, options) => {
71
+ try {
72
+ const graph = resolveGraph(options, false);
73
+ const depth = parseInt(options.depth || '4', 10);
74
+ // Parse refs: true/string means enabled, number sets max depth (default 1, max 4)
75
+ const refsDepth = options.refs !== undefined
76
+ ? Math.min(4, Math.max(1, parseInt(options.refs, 10) || 1))
77
+ : 0;
78
+ const outputOptions = {
79
+ json: options.json,
80
+ flat: options.flat,
81
+ debug: options.debug
82
+ };
83
+ if (options.debug) {
84
+ printDebug('Target', target);
85
+ printDebug('Graph', options.graph || 'default');
86
+ printDebug('Options', { depth, refs: refsDepth || 'off', ...outputOptions });
87
+ }
88
+ // Handle --todo or --done flags
89
+ if (options.todo || options.done) {
90
+ const status = options.todo ? 'TODO' : 'DONE';
91
+ if (options.debug) {
92
+ printDebug('Status search', { status, page: options.page, include: options.include, exclude: options.exclude });
93
+ }
94
+ const searchOps = new SearchOperations(graph);
95
+ const result = await searchOps.searchByStatus(status, options.page, options.include, options.exclude);
96
+ console.log(formatTodoOutput(result.matches, status, outputOptions));
97
+ return;
98
+ }
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];
113
+ if (options.debug) {
114
+ printDebug('Fetching block', { uid: blockUid, depth });
115
+ }
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`);
120
+ }
121
+ // Resolve block references if requested
122
+ if (refsDepth > 0) {
123
+ block = await resolveBlockRefs(graph, block, refsDepth);
124
+ }
125
+ console.log(formatBlockOutput(block, outputOptions));
126
+ }
127
+ else {
128
+ // Fetch page by title
129
+ if (options.debug) {
130
+ printDebug('Fetching page', { title: resolvedTarget, depth });
131
+ }
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);
139
+ }
140
+ catch {
141
+ // Result is already formatted as string (e.g., "Page Title (no content found)")
142
+ console.log(result);
143
+ return;
144
+ }
145
+ }
146
+ else {
147
+ blocks = result;
148
+ }
149
+ // Resolve block references if requested
150
+ if (refsDepth > 0) {
151
+ blocks = await resolveBlocksRefs(graph, blocks, refsDepth);
152
+ }
153
+ console.log(formatPageOutput(resolvedTarget, blocks, outputOptions));
154
+ }
155
+ }
156
+ catch (error) {
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ exitWithError(message);
159
+ }
160
+ });
161
+ }
@@ -0,0 +1,135 @@
1
+ import { Command } from 'commander';
2
+ import { SearchOperations } from '../../tools/operations/search/index.js';
3
+ import { printDebug, exitWithError } from '../utils/output.js';
4
+ import { resolveGraph } from '../utils/graph.js';
5
+ /**
6
+ * Format results grouped by page (default output)
7
+ */
8
+ function formatGrouped(matches, maxContentLength = 60) {
9
+ if (matches.length === 0) {
10
+ return 'No references found.';
11
+ }
12
+ // Group by page title
13
+ const byPage = new Map();
14
+ for (const match of matches) {
15
+ const pageTitle = match.page_title || 'Unknown Page';
16
+ if (!byPage.has(pageTitle)) {
17
+ byPage.set(pageTitle, []);
18
+ }
19
+ byPage.get(pageTitle).push(match);
20
+ }
21
+ // Format output
22
+ const lines = [];
23
+ for (const [pageTitle, pageMatches] of byPage) {
24
+ lines.push(`[[${pageTitle}]]`);
25
+ for (const match of pageMatches) {
26
+ const truncated = match.content.length > maxContentLength
27
+ ? match.content.slice(0, maxContentLength) + '...'
28
+ : match.content;
29
+ lines.push(` ${match.block_uid} ${truncated}`);
30
+ }
31
+ lines.push('');
32
+ }
33
+ return lines.join('\n').trim();
34
+ }
35
+ /**
36
+ * Format results as raw lines (UID + content)
37
+ */
38
+ function formatRaw(matches, maxContentLength = 60) {
39
+ if (matches.length === 0) {
40
+ return 'No references found.';
41
+ }
42
+ return matches
43
+ .map(match => {
44
+ const truncated = match.content.length > maxContentLength
45
+ ? match.content.slice(0, maxContentLength) + '...'
46
+ : match.content;
47
+ return `${match.block_uid} ${truncated}`;
48
+ })
49
+ .join('\n');
50
+ }
51
+ /**
52
+ * Parse identifier to determine if it's a block UID or page title
53
+ */
54
+ function parseIdentifier(identifier) {
55
+ // Check for ((uid)) format
56
+ const blockRefMatch = identifier.match(/^\(\(([^)]+)\)\)$/);
57
+ if (blockRefMatch) {
58
+ return { block_uid: blockRefMatch[1] };
59
+ }
60
+ // Check for [[page]] or #[[page]] format - extract page title
61
+ const pageRefMatch = identifier.match(/^#?\[\[(.+)\]\]$/);
62
+ if (pageRefMatch) {
63
+ return { title: pageRefMatch[1] };
64
+ }
65
+ // Check for #tag format
66
+ if (identifier.startsWith('#')) {
67
+ return { title: identifier.slice(1) };
68
+ }
69
+ // Default: treat as page title
70
+ return { title: identifier };
71
+ }
72
+ export function createRefsCommand() {
73
+ return new Command('refs')
74
+ .description('Find all blocks that reference a page, tag, or block')
75
+ .argument('<identifier>', 'Page title, #tag, [[Page]], or ((block-uid))')
76
+ .option('-n, --limit <n>', 'Limit number of results', '50')
77
+ .option('--json', 'Output as JSON array')
78
+ .option('--raw', 'Output raw UID + content lines (no grouping)')
79
+ .option('--debug', 'Show query metadata')
80
+ .option('-g, --graph <name>', 'Target graph key (for multi-graph mode)')
81
+ .addHelpText('after', `
82
+ Examples:
83
+ # Page references
84
+ roam refs "Project Alpha" # Blocks linking to page
85
+ roam refs "[[Meeting Notes]]" # With bracket syntax
86
+ roam refs "#TODO" # Blocks with #TODO tag
87
+
88
+ # Block references
89
+ roam refs "((abc123def))" # Blocks embedding this block
90
+
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
+ `)
96
+ .action(async (identifier, options) => {
97
+ try {
98
+ const graph = resolveGraph(options, false);
99
+ 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);
111
+ }
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));
122
+ }
123
+ else if (options.raw) {
124
+ console.log(formatRaw(limitedMatches));
125
+ }
126
+ else {
127
+ console.log(formatGrouped(limitedMatches));
128
+ }
129
+ }
130
+ catch (error) {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ exitWithError(message);
133
+ }
134
+ });
135
+ }