roam-research-mcp 1.4.0 → 1.6.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.
- package/README.md +167 -25
- package/build/cli/commands/get.js +79 -0
- package/build/cli/commands/refs.js +122 -0
- package/build/cli/commands/save.js +121 -0
- package/build/cli/commands/search.js +79 -0
- package/build/cli/roam.js +18 -0
- package/build/cli/utils/output.js +88 -0
- package/build/search/block-ref-search.js +34 -7
- package/build/tools/schemas.js +6 -2
- package/package.json +4 -3
- package/build/cli/import-markdown.js +0 -98
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
[](https://github.com/2b3pro/roam-research-mcp/blob/main/LICENSE)
|
|
9
9
|
|
|
10
|
-
A Model Context Protocol (MCP) server that provides comprehensive access to Roam Research's API functionality.
|
|
10
|
+
A Model Context Protocol (MCP) server and standalone CLI that provides comprehensive access to Roam Research's API functionality. The MCP server enables AI assistants like Claude to interact with your Roam Research graph through a standardized interface, while the CLI (`roam`) lets you fetch, search, and import content directly from the command line. Supports standard input/output (stdio) and HTTP Stream communication. (A WORK-IN-PROGRESS, personal project not officially endorsed by Roam Research)
|
|
11
11
|
|
|
12
12
|
<a href="https://glama.ai/mcp/servers/fzfznyaflu"><img width="380" height="200" src="https://glama.ai/mcp/servers/fzfznyaflu/badge" alt="Roam Research MCP server" /></a>
|
|
13
13
|
<a href="https://mseep.ai/app/2b3pro-roam-research-mcp"><img width="380" height="200" src="https://mseep.net/pr/2b3pro-roam-research-mcp-badge.png" alt="MseeP.ai Security Assessment Badge" /></a>
|
|
@@ -83,21 +83,129 @@ Alternatively, if you have a `.env` file in the project root (which is copied in
|
|
|
83
83
|
docker run -p 3000:3000 -p 8088:8088 --env-file .env roam-research-mcp
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
## Standalone CLI: roam
|
|
86
|
+
## Standalone CLI: `roam`
|
|
87
87
|
|
|
88
|
-
A standalone command-line tool for
|
|
88
|
+
A standalone command-line tool for interacting with Roam Research directly, without running the MCP server. Provides four subcommands: `get`, `search`, `save`, and `refs`.
|
|
89
89
|
|
|
90
|
-
###
|
|
90
|
+
### Installation
|
|
91
|
+
|
|
92
|
+
After building the project, make the command globally available:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm link
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or run directly without linking:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
node build/cli/roam.js <command> [options]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Requirements
|
|
105
|
+
|
|
106
|
+
Same environment variables as the MCP server:
|
|
107
|
+
- `ROAM_API_TOKEN`: Your Roam Research API token
|
|
108
|
+
- `ROAM_GRAPH_NAME`: Your Roam graph name
|
|
109
|
+
|
|
110
|
+
Configure via `.env` file in the project root or set as environment variables.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
### `roam get` - Fetch pages or blocks
|
|
115
|
+
|
|
116
|
+
Fetch content from Roam and output as markdown or JSON.
|
|
91
117
|
|
|
92
118
|
```bash
|
|
93
|
-
#
|
|
94
|
-
|
|
119
|
+
# Fetch a page by title
|
|
120
|
+
roam get "Daily Notes"
|
|
121
|
+
|
|
122
|
+
# Fetch a block by UID
|
|
123
|
+
roam get "((AbCdEfGhI))"
|
|
124
|
+
roam get AbCdEfGhI
|
|
125
|
+
|
|
126
|
+
# Output as JSON
|
|
127
|
+
roam get "Daily Notes" --json
|
|
128
|
+
|
|
129
|
+
# Control child depth (default: 4)
|
|
130
|
+
roam get "Daily Notes" --depth 2
|
|
131
|
+
|
|
132
|
+
# Flatten hierarchy
|
|
133
|
+
roam get "Daily Notes" --flat
|
|
134
|
+
|
|
135
|
+
# Debug mode
|
|
136
|
+
roam get "Daily Notes" --debug
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Options:**
|
|
140
|
+
- `--json` - Output as JSON instead of markdown
|
|
141
|
+
- `--depth <n>` - Child levels to fetch (default: 4)
|
|
142
|
+
- `--refs <n>` - Block ref expansion depth (default: 1)
|
|
143
|
+
- `--flat` - Flatten hierarchy to single-level list
|
|
144
|
+
- `--debug` - Show query metadata
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
### `roam search` - Search content
|
|
149
|
+
|
|
150
|
+
Search for blocks containing text or tags.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# Full-text search
|
|
154
|
+
roam search "keyword"
|
|
155
|
+
|
|
156
|
+
# Multiple terms (AND logic)
|
|
157
|
+
roam search "term1" "term2"
|
|
95
158
|
|
|
96
|
-
#
|
|
97
|
-
|
|
159
|
+
# Tag-only search
|
|
160
|
+
roam search --tag "[[Project]]"
|
|
161
|
+
roam search --tag "#TODO"
|
|
162
|
+
|
|
163
|
+
# Text + tag filter
|
|
164
|
+
roam search "meeting" --tag "[[Work]]"
|
|
165
|
+
|
|
166
|
+
# Scope to a specific page
|
|
167
|
+
roam search "task" --page "Daily Notes"
|
|
168
|
+
|
|
169
|
+
# Case-insensitive search
|
|
170
|
+
roam search "keyword" -i
|
|
171
|
+
|
|
172
|
+
# Limit results (default: 20)
|
|
173
|
+
roam search "keyword" -n 50
|
|
174
|
+
|
|
175
|
+
# Output as JSON
|
|
176
|
+
roam search "keyword" --json
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Options:**
|
|
180
|
+
- `--tag <tag>` - Filter by tag (e.g., `#TODO` or `[[Project]]`)
|
|
181
|
+
- `--page <title>` - Scope search to a specific page
|
|
182
|
+
- `-i, --case-insensitive` - Case-insensitive search
|
|
183
|
+
- `-n, --limit <n>` - Limit number of results (default: 20)
|
|
184
|
+
- `--json` - Output as JSON
|
|
185
|
+
- `--debug` - Show query metadata
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### `roam save` - Import markdown
|
|
190
|
+
|
|
191
|
+
Import markdown content to Roam, creating or updating pages.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
# From a file (title derived from filename)
|
|
195
|
+
roam save document.md
|
|
196
|
+
|
|
197
|
+
# With explicit title
|
|
198
|
+
roam save document.md --title "Meeting Notes"
|
|
199
|
+
|
|
200
|
+
# Update existing page with smart diff (preserves block UIDs)
|
|
201
|
+
roam save document.md --update
|
|
202
|
+
|
|
203
|
+
# From stdin (requires --title)
|
|
204
|
+
cat notes.md | roam save --title "Quick Notes"
|
|
205
|
+
pbpaste | roam save --title "Clipboard Content"
|
|
98
206
|
|
|
99
207
|
# From here-doc
|
|
100
|
-
roam
|
|
208
|
+
roam save --title "Quick Note" << EOF
|
|
101
209
|
# Heading
|
|
102
210
|
- Item 1
|
|
103
211
|
- Item 2
|
|
@@ -105,34 +213,68 @@ roam-import "Quick Note" << EOF
|
|
|
105
213
|
EOF
|
|
106
214
|
```
|
|
107
215
|
|
|
108
|
-
|
|
216
|
+
**Options:**
|
|
217
|
+
- `--title <title>` - Page title (defaults to filename without `.md`)
|
|
218
|
+
- `--update` - Update existing page using smart diff (preserves block UIDs)
|
|
219
|
+
- `--debug` - Show debug information
|
|
109
220
|
|
|
110
|
-
|
|
221
|
+
**Features:**
|
|
111
222
|
- Creates a new page with the specified title (or appends to existing page)
|
|
112
223
|
- Automatically links the new page from today's daily page
|
|
113
|
-
- Converts standard markdown to Roam-flavored markdown
|
|
224
|
+
- Converts standard markdown to Roam-flavored markdown
|
|
225
|
+
- Smart diff mode (`--update`) preserves block UIDs for existing content
|
|
114
226
|
|
|
115
|
-
|
|
227
|
+
---
|
|
116
228
|
|
|
117
|
-
|
|
229
|
+
### `roam refs` - Find references
|
|
230
|
+
|
|
231
|
+
Find blocks that reference a page or block (backlinks).
|
|
118
232
|
|
|
119
233
|
```bash
|
|
120
|
-
|
|
121
|
-
|
|
234
|
+
# Find references to a page
|
|
235
|
+
roam refs "Project Alpha"
|
|
236
|
+
roam refs "December 30th, 2025"
|
|
122
237
|
|
|
123
|
-
|
|
238
|
+
# Find references to a tag
|
|
239
|
+
roam refs "#TODO"
|
|
240
|
+
roam refs "[[Meeting Notes]]"
|
|
124
241
|
|
|
125
|
-
|
|
126
|
-
|
|
242
|
+
# Find references to a block
|
|
243
|
+
roam refs "((AbCdEfGhI))"
|
|
244
|
+
|
|
245
|
+
# Limit results
|
|
246
|
+
roam refs "My Page" -n 100
|
|
247
|
+
|
|
248
|
+
# Output as JSON (for LLM/programmatic use)
|
|
249
|
+
roam refs "My Page" --json
|
|
250
|
+
|
|
251
|
+
# Raw output (for piping)
|
|
252
|
+
roam refs "My Page" --raw
|
|
127
253
|
```
|
|
128
254
|
|
|
129
|
-
|
|
255
|
+
**Options:**
|
|
256
|
+
- `-n, --limit <n>` - Limit number of results (default: 50)
|
|
257
|
+
- `--json` - Output as JSON array
|
|
258
|
+
- `--raw` - Output raw UID + content lines (no grouping)
|
|
259
|
+
- `--debug` - Show query metadata
|
|
130
260
|
|
|
131
|
-
|
|
132
|
-
- `ROAM_API_TOKEN`: Your Roam Research API token
|
|
133
|
-
- `ROAM_GRAPH_NAME`: Your Roam graph name
|
|
261
|
+
**Output Formats:**
|
|
134
262
|
|
|
135
|
-
|
|
263
|
+
Default output groups results by page:
|
|
264
|
+
```
|
|
265
|
+
[[Reading List: Inbox]]
|
|
266
|
+
tiTqNBvYA Date Captured:: [[December 30th, 2025]]
|
|
267
|
+
|
|
268
|
+
[[Week 53, 2025]]
|
|
269
|
+
g0ur1z7Bs [Sun 28]([[December 28th, 2025]]) | [Mon 29](...
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
JSON output for programmatic use:
|
|
273
|
+
```json
|
|
274
|
+
[
|
|
275
|
+
{"uid": "tiTqNBvYA", "content": "Date Captured:: [[December 30th, 2025]]", "page": "Reading List: Inbox"}
|
|
276
|
+
]
|
|
277
|
+
```
|
|
136
278
|
|
|
137
279
|
---
|
|
138
280
|
|
|
@@ -167,7 +309,7 @@ The server provides powerful tools for interacting with Roam Research:
|
|
|
167
309
|
5. `roam_import_markdown`: Import nested markdown content under a specific block. (Internally uses `roam_process_batch_actions`.)
|
|
168
310
|
6. `roam_add_todo`: Add a list of todo items to today's daily page. (Internally uses `roam_process_batch_actions`.)
|
|
169
311
|
7. `roam_create_outline`: Add a structured outline to an existing page or block, with support for `children_view_type`. Best for simpler, sequential outlines. For complex nesting (e.g., tables), consider `roam_process_batch_actions`. If `page_title_uid` and `block_text_uid` are both blank, content defaults to the daily page. (Internally uses `roam_process_batch_actions`.)
|
|
170
|
-
8. `roam_search_block_refs`: Search for block references within a page or across the entire graph.
|
|
312
|
+
8. `roam_search_block_refs`: Search for block references within a page or across the entire graph. Now supports `title` parameter to find blocks referencing a page title using `:block/refs` (captures `[[page]]` and `#tag` links semantically).
|
|
171
313
|
9. `roam_search_hierarchy`: Search for parent or child blocks in the block hierarchy.
|
|
172
314
|
10. `roam_find_pages_modified_today`: Find pages that have been modified today (since midnight), with pagination and sorting options.
|
|
173
315
|
11. `roam_search_by_text`: Search for blocks containing specific text across all pages or within a specific page. This tool supports pagination via the `limit` and `offset` parameters.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
3
|
+
import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
|
|
4
|
+
import { PageOperations } from '../../tools/operations/pages.js';
|
|
5
|
+
import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
|
|
6
|
+
import { formatPageOutput, formatBlockOutput, printDebug, exitWithError } from '../utils/output.js';
|
|
7
|
+
// Block UID pattern: 9 alphanumeric characters, optionally wrapped in (( ))
|
|
8
|
+
const BLOCK_UID_PATTERN = /^(?:\(\()?([a-zA-Z0-9_-]{9})(?:\)\))?$/;
|
|
9
|
+
export function createGetCommand() {
|
|
10
|
+
return new Command('get')
|
|
11
|
+
.description('Fetch a page or block from Roam')
|
|
12
|
+
.argument('<target>', 'Page title or block UID (e.g., "Page Title" or "((AbCdEfGhI))")')
|
|
13
|
+
.option('--json', 'Output as JSON instead of markdown')
|
|
14
|
+
.option('--depth <n>', 'Child levels to fetch (default: 4)', '4')
|
|
15
|
+
.option('--refs <n>', 'Block ref expansion depth (default: 1)', '1')
|
|
16
|
+
.option('--flat', 'Flatten hierarchy to single-level list')
|
|
17
|
+
.option('--debug', 'Show query metadata')
|
|
18
|
+
.action(async (target, options) => {
|
|
19
|
+
try {
|
|
20
|
+
const graph = initializeGraph({
|
|
21
|
+
token: API_TOKEN,
|
|
22
|
+
graph: GRAPH_NAME
|
|
23
|
+
});
|
|
24
|
+
const depth = parseInt(options.depth || '4', 10);
|
|
25
|
+
const outputOptions = {
|
|
26
|
+
json: options.json,
|
|
27
|
+
flat: options.flat,
|
|
28
|
+
debug: options.debug
|
|
29
|
+
};
|
|
30
|
+
if (options.debug) {
|
|
31
|
+
printDebug('Target', target);
|
|
32
|
+
printDebug('Options', { depth, refs: options.refs, ...outputOptions });
|
|
33
|
+
}
|
|
34
|
+
// Check if target is a block UID
|
|
35
|
+
const uidMatch = target.match(BLOCK_UID_PATTERN);
|
|
36
|
+
if (uidMatch) {
|
|
37
|
+
// Fetch block by UID
|
|
38
|
+
const blockUid = uidMatch[1];
|
|
39
|
+
if (options.debug) {
|
|
40
|
+
printDebug('Fetching block', { uid: blockUid, depth });
|
|
41
|
+
}
|
|
42
|
+
const blockOps = new BlockRetrievalOperations(graph);
|
|
43
|
+
const block = await blockOps.fetchBlockWithChildren(blockUid, depth);
|
|
44
|
+
if (!block) {
|
|
45
|
+
exitWithError(`Block with UID "${blockUid}" not found`);
|
|
46
|
+
}
|
|
47
|
+
console.log(formatBlockOutput(block, outputOptions));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Fetch page by title
|
|
51
|
+
if (options.debug) {
|
|
52
|
+
printDebug('Fetching page', { title: target, depth });
|
|
53
|
+
}
|
|
54
|
+
const pageOps = new PageOperations(graph);
|
|
55
|
+
const result = await pageOps.fetchPageByTitle(target, 'raw');
|
|
56
|
+
// Parse the raw result
|
|
57
|
+
let blocks;
|
|
58
|
+
if (typeof result === 'string') {
|
|
59
|
+
try {
|
|
60
|
+
blocks = JSON.parse(result);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Result is already formatted as string (e.g., "Page Title (no content found)")
|
|
64
|
+
console.log(result);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
blocks = result;
|
|
70
|
+
}
|
|
71
|
+
console.log(formatPageOutput(target, blocks, outputOptions));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
exitWithError(message);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
3
|
+
import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
|
|
4
|
+
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
5
|
+
import { printDebug, exitWithError } from '../utils/output.js';
|
|
6
|
+
/**
|
|
7
|
+
* Format results grouped by page (default output)
|
|
8
|
+
*/
|
|
9
|
+
function formatGrouped(matches, maxContentLength = 60) {
|
|
10
|
+
if (matches.length === 0) {
|
|
11
|
+
return 'No references found.';
|
|
12
|
+
}
|
|
13
|
+
// Group by page title
|
|
14
|
+
const byPage = new Map();
|
|
15
|
+
for (const match of matches) {
|
|
16
|
+
const pageTitle = match.page_title || 'Unknown Page';
|
|
17
|
+
if (!byPage.has(pageTitle)) {
|
|
18
|
+
byPage.set(pageTitle, []);
|
|
19
|
+
}
|
|
20
|
+
byPage.get(pageTitle).push(match);
|
|
21
|
+
}
|
|
22
|
+
// Format output
|
|
23
|
+
const lines = [];
|
|
24
|
+
for (const [pageTitle, pageMatches] of byPage) {
|
|
25
|
+
lines.push(`[[${pageTitle}]]`);
|
|
26
|
+
for (const match of pageMatches) {
|
|
27
|
+
const truncated = match.content.length > maxContentLength
|
|
28
|
+
? match.content.slice(0, maxContentLength) + '...'
|
|
29
|
+
: match.content;
|
|
30
|
+
lines.push(` ${match.block_uid} ${truncated}`);
|
|
31
|
+
}
|
|
32
|
+
lines.push('');
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n').trim();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Format results as raw lines (UID + content)
|
|
38
|
+
*/
|
|
39
|
+
function formatRaw(matches, maxContentLength = 60) {
|
|
40
|
+
if (matches.length === 0) {
|
|
41
|
+
return 'No references found.';
|
|
42
|
+
}
|
|
43
|
+
return matches
|
|
44
|
+
.map(match => {
|
|
45
|
+
const truncated = match.content.length > maxContentLength
|
|
46
|
+
? match.content.slice(0, maxContentLength) + '...'
|
|
47
|
+
: match.content;
|
|
48
|
+
return `${match.block_uid} ${truncated}`;
|
|
49
|
+
})
|
|
50
|
+
.join('\n');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse identifier to determine if it's a block UID or page title
|
|
54
|
+
*/
|
|
55
|
+
function parseIdentifier(identifier) {
|
|
56
|
+
// Check for ((uid)) format
|
|
57
|
+
const blockRefMatch = identifier.match(/^\(\(([^)]+)\)\)$/);
|
|
58
|
+
if (blockRefMatch) {
|
|
59
|
+
return { block_uid: blockRefMatch[1] };
|
|
60
|
+
}
|
|
61
|
+
// Check for [[page]] or #[[page]] format - extract page title
|
|
62
|
+
const pageRefMatch = identifier.match(/^#?\[\[(.+)\]\]$/);
|
|
63
|
+
if (pageRefMatch) {
|
|
64
|
+
return { title: pageRefMatch[1] };
|
|
65
|
+
}
|
|
66
|
+
// Check for #tag format
|
|
67
|
+
if (identifier.startsWith('#')) {
|
|
68
|
+
return { title: identifier.slice(1) };
|
|
69
|
+
}
|
|
70
|
+
// Default: treat as page title
|
|
71
|
+
return { title: identifier };
|
|
72
|
+
}
|
|
73
|
+
export function createRefsCommand() {
|
|
74
|
+
return new Command('refs')
|
|
75
|
+
.description('Find blocks referencing a page or block')
|
|
76
|
+
.argument('<identifier>', 'Page title or block UID (use ((uid)) for block refs)')
|
|
77
|
+
.option('-n, --limit <n>', 'Limit number of results', '50')
|
|
78
|
+
.option('--json', 'Output as JSON array')
|
|
79
|
+
.option('--raw', 'Output raw UID + content lines (no grouping)')
|
|
80
|
+
.option('--debug', 'Show query metadata')
|
|
81
|
+
.action(async (identifier, options) => {
|
|
82
|
+
try {
|
|
83
|
+
const graph = initializeGraph({
|
|
84
|
+
token: API_TOKEN,
|
|
85
|
+
graph: GRAPH_NAME
|
|
86
|
+
});
|
|
87
|
+
const limit = parseInt(options.limit || '50', 10);
|
|
88
|
+
const { block_uid, title } = parseIdentifier(identifier);
|
|
89
|
+
if (options.debug) {
|
|
90
|
+
printDebug('Identifier', identifier);
|
|
91
|
+
printDebug('Parsed', { block_uid, title });
|
|
92
|
+
printDebug('Options', options);
|
|
93
|
+
}
|
|
94
|
+
const searchOps = new SearchOperations(graph);
|
|
95
|
+
const result = await searchOps.searchBlockRefs({ block_uid, title });
|
|
96
|
+
if (options.debug) {
|
|
97
|
+
printDebug('Total matches', result.matches.length);
|
|
98
|
+
}
|
|
99
|
+
// Apply limit
|
|
100
|
+
const limitedMatches = result.matches.slice(0, limit);
|
|
101
|
+
// Format output
|
|
102
|
+
if (options.json) {
|
|
103
|
+
const jsonOutput = limitedMatches.map(m => ({
|
|
104
|
+
uid: m.block_uid,
|
|
105
|
+
content: m.content,
|
|
106
|
+
page: m.page_title
|
|
107
|
+
}));
|
|
108
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
109
|
+
}
|
|
110
|
+
else if (options.raw) {
|
|
111
|
+
console.log(formatRaw(limitedMatches));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
console.log(formatGrouped(limitedMatches));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
119
|
+
exitWithError(message);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
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
|
+
import { PageOperations } from '../../tools/operations/pages.js';
|
|
7
|
+
import { parseMarkdown } from '../../markdown-utils.js';
|
|
8
|
+
import { printDebug, exitWithError } from '../utils/output.js';
|
|
9
|
+
/**
|
|
10
|
+
* Flatten nested MarkdownNode[] to flat array with absolute levels
|
|
11
|
+
*/
|
|
12
|
+
function flattenNodes(nodes, baseLevel = 1) {
|
|
13
|
+
const result = [];
|
|
14
|
+
for (const node of nodes) {
|
|
15
|
+
result.push({
|
|
16
|
+
text: node.content,
|
|
17
|
+
level: baseLevel,
|
|
18
|
+
...(node.heading_level && { heading: node.heading_level })
|
|
19
|
+
});
|
|
20
|
+
if (node.children.length > 0) {
|
|
21
|
+
result.push(...flattenNodes(node.children, baseLevel + 1));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Read all input from stdin
|
|
28
|
+
*/
|
|
29
|
+
async function readStdin() {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
for await (const chunk of process.stdin) {
|
|
32
|
+
chunks.push(chunk);
|
|
33
|
+
}
|
|
34
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
35
|
+
}
|
|
36
|
+
export function createSaveCommand() {
|
|
37
|
+
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')
|
|
42
|
+
.option('--debug', 'Show debug information')
|
|
43
|
+
.action(async (file, options) => {
|
|
44
|
+
try {
|
|
45
|
+
let markdownContent;
|
|
46
|
+
let pageTitle;
|
|
47
|
+
if (file) {
|
|
48
|
+
// Read from file
|
|
49
|
+
try {
|
|
50
|
+
markdownContent = readFileSync(file, 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
exitWithError(`Could not read file: ${file}`);
|
|
54
|
+
}
|
|
55
|
+
// Derive title from filename if not provided
|
|
56
|
+
pageTitle = options.title || basename(file, '.md');
|
|
57
|
+
}
|
|
58
|
+
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"');
|
|
62
|
+
}
|
|
63
|
+
if (!options.title) {
|
|
64
|
+
exitWithError('--title is required when piping from stdin');
|
|
65
|
+
}
|
|
66
|
+
markdownContent = await readStdin();
|
|
67
|
+
pageTitle = options.title;
|
|
68
|
+
}
|
|
69
|
+
if (!markdownContent.trim()) {
|
|
70
|
+
exitWithError('Empty content received');
|
|
71
|
+
}
|
|
72
|
+
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)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
exitWithError(`Failed to update page '${pageTitle}'`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
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');
|
|
103
|
+
}
|
|
104
|
+
if (options.debug) {
|
|
105
|
+
printDebug('Parsed blocks', contentBlocks.length);
|
|
106
|
+
}
|
|
107
|
+
const result = await pageOps.createPage(pageTitle, contentBlocks);
|
|
108
|
+
if (result.success) {
|
|
109
|
+
console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
exitWithError(`Failed to create page '${pageTitle}'`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
118
|
+
exitWithError(message);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
3
|
+
import { API_TOKEN, GRAPH_NAME } from '../../config/environment.js';
|
|
4
|
+
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
5
|
+
import { formatSearchResults, printDebug, exitWithError } from '../utils/output.js';
|
|
6
|
+
export function createSearchCommand() {
|
|
7
|
+
return new Command('search')
|
|
8
|
+
.description('Search for content in Roam')
|
|
9
|
+
.argument('[terms...]', 'Search terms (multiple terms use AND logic)')
|
|
10
|
+
.option('--tag <tag>', 'Filter by tag (e.g., "#TODO" or "[[Project]]")')
|
|
11
|
+
.option('--page <title>', 'Scope search to a specific page')
|
|
12
|
+
.option('-i, --case-insensitive', 'Case-insensitive search')
|
|
13
|
+
.option('-n, --limit <n>', 'Limit number of results (default: 20)', '20')
|
|
14
|
+
.option('--json', 'Output as JSON')
|
|
15
|
+
.option('--debug', 'Show query metadata')
|
|
16
|
+
.action(async (terms, options) => {
|
|
17
|
+
try {
|
|
18
|
+
const graph = initializeGraph({
|
|
19
|
+
token: API_TOKEN,
|
|
20
|
+
graph: GRAPH_NAME
|
|
21
|
+
});
|
|
22
|
+
const limit = parseInt(options.limit || '20', 10);
|
|
23
|
+
const outputOptions = {
|
|
24
|
+
json: options.json,
|
|
25
|
+
debug: options.debug
|
|
26
|
+
};
|
|
27
|
+
if (options.debug) {
|
|
28
|
+
printDebug('Search terms', terms);
|
|
29
|
+
printDebug('Options', options);
|
|
30
|
+
}
|
|
31
|
+
const searchOps = new SearchOperations(graph);
|
|
32
|
+
// Determine search type based on options
|
|
33
|
+
if (options.tag && terms.length === 0) {
|
|
34
|
+
// Tag-only search
|
|
35
|
+
const tagName = options.tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
|
|
36
|
+
if (options.debug) {
|
|
37
|
+
printDebug('Tag search', { tag: tagName, page: options.page });
|
|
38
|
+
}
|
|
39
|
+
const result = await searchOps.searchForTag(tagName, options.page);
|
|
40
|
+
const limitedMatches = result.matches.slice(0, limit);
|
|
41
|
+
console.log(formatSearchResults(limitedMatches, outputOptions));
|
|
42
|
+
}
|
|
43
|
+
else if (terms.length > 0) {
|
|
44
|
+
// Text search (with optional tag filter)
|
|
45
|
+
const searchText = terms.join(' ');
|
|
46
|
+
if (options.debug) {
|
|
47
|
+
printDebug('Text search', { text: searchText, page: options.page, tag: options.tag });
|
|
48
|
+
}
|
|
49
|
+
const result = await searchOps.searchByText({
|
|
50
|
+
text: searchText,
|
|
51
|
+
page_title_uid: options.page
|
|
52
|
+
});
|
|
53
|
+
// Apply client-side filters
|
|
54
|
+
let matches = result.matches;
|
|
55
|
+
// Case-insensitive filter if requested
|
|
56
|
+
if (options.caseInsensitive) {
|
|
57
|
+
const lowerSearchText = searchText.toLowerCase();
|
|
58
|
+
matches = matches.filter(m => m.content.toLowerCase().includes(lowerSearchText));
|
|
59
|
+
}
|
|
60
|
+
// Tag filter if provided
|
|
61
|
+
if (options.tag) {
|
|
62
|
+
const tagPattern = options.tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
|
|
63
|
+
matches = matches.filter(m => m.content.includes(`[[${tagPattern}]]`) ||
|
|
64
|
+
m.content.includes(`#${tagPattern}`) ||
|
|
65
|
+
m.content.includes(`#[[${tagPattern}]]`));
|
|
66
|
+
}
|
|
67
|
+
// Apply limit
|
|
68
|
+
console.log(formatSearchResults(matches.slice(0, limit), outputOptions));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
exitWithError('Please provide search terms or use --tag to search by tag');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
exitWithError(message);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { createGetCommand } from './commands/get.js';
|
|
4
|
+
import { createSearchCommand } from './commands/search.js';
|
|
5
|
+
import { createSaveCommand } from './commands/save.js';
|
|
6
|
+
import { createRefsCommand } from './commands/refs.js';
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name('roam')
|
|
10
|
+
.description('CLI for Roam Research')
|
|
11
|
+
.version('1.6.0');
|
|
12
|
+
// Register subcommands
|
|
13
|
+
program.addCommand(createGetCommand());
|
|
14
|
+
program.addCommand(createSearchCommand());
|
|
15
|
+
program.addCommand(createSaveCommand());
|
|
16
|
+
program.addCommand(createRefsCommand());
|
|
17
|
+
// Parse arguments
|
|
18
|
+
program.parse();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert RoamBlock hierarchy to markdown with proper indentation
|
|
3
|
+
*/
|
|
4
|
+
export function blocksToMarkdown(blocks, level = 0) {
|
|
5
|
+
return blocks
|
|
6
|
+
.map(block => {
|
|
7
|
+
const indent = ' '.repeat(level);
|
|
8
|
+
let md;
|
|
9
|
+
// Check block heading level and format accordingly
|
|
10
|
+
if (block.heading && block.heading > 0) {
|
|
11
|
+
const hashtags = '#'.repeat(block.heading);
|
|
12
|
+
md = `${indent}${hashtags} ${block.string}`;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
md = `${indent}- ${block.string}`;
|
|
16
|
+
}
|
|
17
|
+
if (block.children && block.children.length > 0) {
|
|
18
|
+
md += '\n' + blocksToMarkdown(block.children, level + 1);
|
|
19
|
+
}
|
|
20
|
+
return md;
|
|
21
|
+
})
|
|
22
|
+
.join('\n');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Flatten block hierarchy to single-level list
|
|
26
|
+
*/
|
|
27
|
+
export function flattenBlocks(blocks, result = []) {
|
|
28
|
+
for (const block of blocks) {
|
|
29
|
+
result.push({ ...block, children: [] });
|
|
30
|
+
if (block.children && block.children.length > 0) {
|
|
31
|
+
flattenBlocks(block.children, result);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Format page content for output
|
|
38
|
+
*/
|
|
39
|
+
export function formatPageOutput(title, blocks, options) {
|
|
40
|
+
if (options.json) {
|
|
41
|
+
const data = options.flat ? flattenBlocks(blocks) : blocks;
|
|
42
|
+
return JSON.stringify({ title, children: data }, null, 2);
|
|
43
|
+
}
|
|
44
|
+
const displayBlocks = options.flat ? flattenBlocks(blocks) : blocks;
|
|
45
|
+
return `# ${title}\n\n${blocksToMarkdown(displayBlocks)}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Format block content for output
|
|
49
|
+
*/
|
|
50
|
+
export function formatBlockOutput(block, options) {
|
|
51
|
+
if (options.json) {
|
|
52
|
+
const data = options.flat ? flattenBlocks([block]) : block;
|
|
53
|
+
return JSON.stringify(data, null, 2);
|
|
54
|
+
}
|
|
55
|
+
const displayBlocks = options.flat ? flattenBlocks([block]) : [block];
|
|
56
|
+
return blocksToMarkdown(displayBlocks);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Format search results for output
|
|
60
|
+
*/
|
|
61
|
+
export function formatSearchResults(results, options) {
|
|
62
|
+
if (options.json) {
|
|
63
|
+
return JSON.stringify(results, null, 2);
|
|
64
|
+
}
|
|
65
|
+
if (results.length === 0) {
|
|
66
|
+
return 'No results found.';
|
|
67
|
+
}
|
|
68
|
+
let output = `Found ${results.length} result(s):\n\n`;
|
|
69
|
+
results.forEach((result, index) => {
|
|
70
|
+
const pageInfo = result.page_title ? ` (${result.page_title})` : '';
|
|
71
|
+
output += `[${index + 1}] ${result.block_uid}${pageInfo}\n`;
|
|
72
|
+
output += ` ${result.content}\n\n`;
|
|
73
|
+
});
|
|
74
|
+
return output.trim();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Print debug information
|
|
78
|
+
*/
|
|
79
|
+
export function printDebug(label, data) {
|
|
80
|
+
console.error(`[DEBUG] ${label}:`, JSON.stringify(data, null, 2));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Print error message and exit
|
|
84
|
+
*/
|
|
85
|
+
export function exitWithError(message, code = 1) {
|
|
86
|
+
console.error(`Error: ${message}`);
|
|
87
|
+
process.exit(code);
|
|
88
|
+
}
|
|
@@ -8,17 +8,42 @@ export class BlockRefSearchHandler extends BaseSearchHandler {
|
|
|
8
8
|
this.params = params;
|
|
9
9
|
}
|
|
10
10
|
async execute() {
|
|
11
|
-
const { block_uid, page_title_uid } = this.params;
|
|
11
|
+
const { block_uid, title, page_title_uid } = this.params;
|
|
12
12
|
// Get target page UID if provided
|
|
13
13
|
let targetPageUid;
|
|
14
14
|
if (page_title_uid) {
|
|
15
15
|
targetPageUid = await SearchUtils.findPageByTitleOrUid(this.graph, page_title_uid);
|
|
16
16
|
}
|
|
17
|
-
// Build query based on whether we're searching for references to a specific block
|
|
18
|
-
// or all block references within a page/graph
|
|
17
|
+
// Build query based on whether we're searching for references to a specific block,
|
|
18
|
+
// a page title, or all block references within a page/graph
|
|
19
19
|
let queryStr;
|
|
20
20
|
let queryParams;
|
|
21
|
-
if (
|
|
21
|
+
if (title) {
|
|
22
|
+
// Search for references to a page by title using :block/refs
|
|
23
|
+
if (targetPageUid) {
|
|
24
|
+
queryStr = `[:find ?block-uid ?block-str
|
|
25
|
+
:in $ ?target-title ?page-uid
|
|
26
|
+
:where [?target :node/title ?target-title]
|
|
27
|
+
[?p :block/uid ?page-uid]
|
|
28
|
+
[?b :block/page ?p]
|
|
29
|
+
[?b :block/refs ?target]
|
|
30
|
+
[?b :block/string ?block-str]
|
|
31
|
+
[?b :block/uid ?block-uid]]`;
|
|
32
|
+
queryParams = [title, targetPageUid];
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
queryStr = `[:find ?block-uid ?block-str ?page-title
|
|
36
|
+
:in $ ?target-title
|
|
37
|
+
:where [?target :node/title ?target-title]
|
|
38
|
+
[?b :block/refs ?target]
|
|
39
|
+
[?b :block/string ?block-str]
|
|
40
|
+
[?b :block/uid ?block-uid]
|
|
41
|
+
[?b :block/page ?p]
|
|
42
|
+
[?p :node/title ?page-title]]`;
|
|
43
|
+
queryParams = [title];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (block_uid) {
|
|
22
47
|
// Search for references to a specific block
|
|
23
48
|
if (targetPageUid) {
|
|
24
49
|
queryStr = `[:find ?block-uid ?block-str
|
|
@@ -69,9 +94,11 @@ export class BlockRefSearchHandler extends BaseSearchHandler {
|
|
|
69
94
|
const resolvedContent = await resolveRefs(this.graph, content);
|
|
70
95
|
return [uid, resolvedContent, pageTitle];
|
|
71
96
|
}));
|
|
72
|
-
const searchDescription =
|
|
73
|
-
? `referencing
|
|
74
|
-
:
|
|
97
|
+
const searchDescription = title
|
|
98
|
+
? `referencing [[${title}]]`
|
|
99
|
+
: block_uid
|
|
100
|
+
? `referencing block ((${block_uid}))`
|
|
101
|
+
: 'containing block references';
|
|
75
102
|
return SearchUtils.formatSearchResults(resolvedResults, searchDescription, !targetPageUid);
|
|
76
103
|
}
|
|
77
104
|
}
|
package/build/tools/schemas.js
CHANGED
|
@@ -259,13 +259,17 @@ export const toolSchemas = {
|
|
|
259
259
|
},
|
|
260
260
|
roam_search_block_refs: {
|
|
261
261
|
name: 'roam_search_block_refs',
|
|
262
|
-
description: 'Search for block references within a page or across the entire graph. Can search for references to a specific block or find all block references.',
|
|
262
|
+
description: 'Search for block references within a page or across the entire graph. Can search for references to a specific block, a page title, or find all block references.',
|
|
263
263
|
inputSchema: {
|
|
264
264
|
type: 'object',
|
|
265
265
|
properties: {
|
|
266
266
|
block_uid: {
|
|
267
267
|
type: 'string',
|
|
268
|
-
description: 'Optional: UID of the block to find references to'
|
|
268
|
+
description: 'Optional: UID of the block to find references to (searches for ((uid)) patterns in text)'
|
|
269
|
+
},
|
|
270
|
+
title: {
|
|
271
|
+
type: 'string',
|
|
272
|
+
description: 'Optional: Page title to find references to (uses :block/refs for [[page]] and #tag links)'
|
|
269
273
|
},
|
|
270
274
|
page_title_uid: {
|
|
271
275
|
type: 'string',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roam-research-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for Roam Research API integration",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -23,13 +23,13 @@
|
|
|
23
23
|
"type": "module",
|
|
24
24
|
"bin": {
|
|
25
25
|
"roam-research-mcp": "build/index.js",
|
|
26
|
-
"roam
|
|
26
|
+
"roam": "build/cli/roam.js"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"build"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"build": "echo \"Using custom instructions: .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md\" && tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js build/cli/
|
|
32
|
+
"build": "echo \"Using custom instructions: .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md\" && tsc && cat Roam_Markdown_Cheatsheet.md .roam/${CUSTOM_INSTRUCTIONS_PREFIX}custom-instructions.md > build/Roam_Markdown_Cheatsheet.md && chmod 755 build/index.js build/cli/roam.js",
|
|
33
33
|
"clean": "rm -rf build",
|
|
34
34
|
"watch": "tsc --watch",
|
|
35
35
|
"inspector": "npx @modelcontextprotocol/inspector build/index.js",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.13.2",
|
|
46
46
|
"@roam-research/roam-api-sdk": "^0.10.0",
|
|
47
|
+
"commander": "^14.0.2",
|
|
47
48
|
"dotenv": "^16.4.7"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { initializeGraph } from '@roam-research/roam-api-sdk';
|
|
3
|
-
import { API_TOKEN, GRAPH_NAME } from '../config/environment.js';
|
|
4
|
-
import { PageOperations } from '../tools/operations/pages.js';
|
|
5
|
-
import { parseMarkdown } from '../markdown-utils.js';
|
|
6
|
-
/**
|
|
7
|
-
* Flatten nested MarkdownNode[] to flat array with absolute levels
|
|
8
|
-
*/
|
|
9
|
-
function flattenNodes(nodes, baseLevel = 1) {
|
|
10
|
-
const result = [];
|
|
11
|
-
for (const node of nodes) {
|
|
12
|
-
result.push({
|
|
13
|
-
text: node.content,
|
|
14
|
-
level: baseLevel,
|
|
15
|
-
...(node.heading_level && { heading: node.heading_level })
|
|
16
|
-
});
|
|
17
|
-
if (node.children.length > 0) {
|
|
18
|
-
result.push(...flattenNodes(node.children, baseLevel + 1));
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return result;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Read all input from stdin
|
|
25
|
-
*/
|
|
26
|
-
async function readStdin() {
|
|
27
|
-
const chunks = [];
|
|
28
|
-
for await (const chunk of process.stdin) {
|
|
29
|
-
chunks.push(chunk);
|
|
30
|
-
}
|
|
31
|
-
return Buffer.concat(chunks).toString('utf-8');
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Show usage help
|
|
35
|
-
*/
|
|
36
|
-
function showUsage() {
|
|
37
|
-
console.error('Usage: roam-import <page-title>');
|
|
38
|
-
console.error('');
|
|
39
|
-
console.error('Reads markdown from stdin and imports to Roam Research.');
|
|
40
|
-
console.error('');
|
|
41
|
-
console.error('Examples:');
|
|
42
|
-
console.error(' cat document.md | roam-import "Meeting Notes"');
|
|
43
|
-
console.error(' pbpaste | roam-import "Ideas"');
|
|
44
|
-
console.error(' echo "- Item 1\\n- Item 2" | roam-import "Quick Note"');
|
|
45
|
-
console.error('');
|
|
46
|
-
console.error('Environment variables required:');
|
|
47
|
-
console.error(' ROAM_API_TOKEN Your Roam Research API token');
|
|
48
|
-
console.error(' ROAM_GRAPH_NAME Your Roam graph name');
|
|
49
|
-
}
|
|
50
|
-
async function main() {
|
|
51
|
-
// Parse CLI arguments
|
|
52
|
-
const args = process.argv.slice(2);
|
|
53
|
-
const pageTitle = args[0];
|
|
54
|
-
if (!pageTitle || pageTitle === '--help' || pageTitle === '-h') {
|
|
55
|
-
showUsage();
|
|
56
|
-
process.exit(pageTitle ? 0 : 1);
|
|
57
|
-
}
|
|
58
|
-
// Check if stdin is a TTY (no input piped)
|
|
59
|
-
if (process.stdin.isTTY) {
|
|
60
|
-
console.error('Error: No input received. Pipe markdown content to this command.');
|
|
61
|
-
console.error('');
|
|
62
|
-
showUsage();
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
65
|
-
// Read markdown from stdin
|
|
66
|
-
const markdownContent = await readStdin();
|
|
67
|
-
if (!markdownContent.trim()) {
|
|
68
|
-
console.error('Error: Empty input received.');
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
71
|
-
// Initialize Roam graph
|
|
72
|
-
const graph = initializeGraph({
|
|
73
|
-
token: API_TOKEN,
|
|
74
|
-
graph: GRAPH_NAME
|
|
75
|
-
});
|
|
76
|
-
// Parse markdown to nodes
|
|
77
|
-
const nodes = parseMarkdown(markdownContent);
|
|
78
|
-
// Flatten nested structure to content blocks
|
|
79
|
-
const contentBlocks = flattenNodes(nodes);
|
|
80
|
-
if (contentBlocks.length === 0) {
|
|
81
|
-
console.error('Error: No content blocks parsed from input.');
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
// Create page with content
|
|
85
|
-
const pageOps = new PageOperations(graph);
|
|
86
|
-
const result = await pageOps.createPage(pageTitle, contentBlocks);
|
|
87
|
-
if (result.success) {
|
|
88
|
-
console.log(`Created page '${pageTitle}' (uid: ${result.uid})`);
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
console.error(`Failed to create page '${pageTitle}'`);
|
|
92
|
-
process.exit(1);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
main().catch((error) => {
|
|
96
|
-
console.error(`Error: ${error.message}`);
|
|
97
|
-
process.exit(1);
|
|
98
|
-
});
|