roam-research-mcp 1.3.2 → 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 CHANGED
@@ -7,7 +7,7 @@
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
  [![GitHub](https://img.shields.io/github/license/2b3pro/roam-research-mcp)](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. This server enables AI assistants like Claude to interact with your Roam Research graph through a standardized interface. It supports standard input/output (stdio) and HTTP Stream communication. (A WORK-IN-PROGRESS, personal project not officially endorsed by Roam Research)
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-import
86
+ ## Standalone CLI: `roam`
87
87
 
88
- A standalone command-line tool for importing markdown content directly into Roam Research, without running the MCP server.
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
- ### Usage
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.
117
+
118
+ ```bash
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"
158
+
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.
91
192
 
92
193
  ```bash
93
- # From a file
94
- cat document.md | roam-import "Meeting Notes"
194
+ # From a file (title derived from filename)
195
+ roam save document.md
95
196
 
96
- # From clipboard (macOS)
97
- pbpaste | roam-import "Ideas"
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-import "Quick Note" << EOF
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
- ### Features
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
- - Reads markdown from stdin
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 (bold, italic, highlights, tasks, code blocks)
224
+ - Converts standard markdown to Roam-flavored markdown
225
+ - Smart diff mode (`--update`) preserves block UIDs for existing content
114
226
 
115
- ### Installation
227
+ ---
116
228
 
117
- After building the project, make the command globally available:
229
+ ### `roam refs` - Find references
230
+
231
+ Find blocks that reference a page or block (backlinks).
118
232
 
119
233
  ```bash
120
- npm link
121
- ```
234
+ # Find references to a page
235
+ roam refs "Project Alpha"
236
+ roam refs "December 30th, 2025"
122
237
 
123
- Or run directly without linking:
238
+ # Find references to a tag
239
+ roam refs "#TODO"
240
+ roam refs "[[Meeting Notes]]"
124
241
 
125
- ```bash
126
- cat document.md | node build/cli/import-markdown.js "Page Title"
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
- ### Requirements
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
- Same environment variables as the MCP server:
132
- - `ROAM_API_TOKEN`: Your Roam Research API token
133
- - `ROAM_GRAPH_NAME`: Your Roam graph name
261
+ **Output Formats:**
134
262
 
135
- Configure via `.env` file in the project root or set as environment variables.
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.
@@ -179,6 +321,7 @@ The server provides powerful tools for interacting with Roam Research:
179
321
  17. `roam_datomic_query`: Execute a custom Datomic query on the Roam graph for advanced data retrieval beyond the available search tools. Now supports client-side regex filtering for enhanced post-query processing. Optimal for complex filtering (including regex), highly complex boolean logic, arbitrary sorting criteria, and proximity search.
180
322
  18. `roam_markdown_cheatsheet`: Provides the content of the Roam Markdown Cheatsheet resource, optionally concatenated with custom instructions if `CUSTOM_INSTRUCTIONS_PATH` environment variable is set.
181
323
  19. `roam_process_batch_actions`: Execute a sequence of low-level block actions (create, update, move, delete) in a single, non-transactional batch. Provides granular control for complex nesting like tables. **Now includes pre-validation** that catches errors before API execution, with structured error responses and automatic rate limit retry with exponential backoff. (Note: For actions on existing blocks or within a specific page context, it is often necessary to first obtain valid page or block UIDs using tools like `roam_fetch_page_by_title`.)
324
+ 20. `roam_update_page_markdown`: Update an existing page with new markdown content using smart diff. **Preserves block UIDs** where possible, keeping references intact across the graph. Uses three-phase matching (exact text → normalized → position fallback) to generate minimal operations. Supports `dry_run` mode to preview changes. Ideal for syncing external markdown files, AI-assisted content updates, and batch modifications without losing block references.
182
325
 
183
326
  **Deprecated Tools**:
184
327
  The following tools have been deprecated as of `v0.36.2` in favor of the more powerful and flexible `roam_process_batch_actions`:
@@ -289,6 +432,25 @@ This demonstrates creating a new page with both text blocks and a table in a sin
289
432
  - A conclusion section"
290
433
  ```
291
434
 
435
+ ### Example 6: Updating a Page with Smart Diff
436
+
437
+ This demonstrates updating an existing page while preserving block UIDs (and therefore block references across the graph).
438
+
439
+ ```
440
+ "Update the 'Project Alpha Planning' page with this revised content, preserving block references:
441
+ - Overview (keep existing UID)
442
+ - Updated Goals section
443
+ - Revised Scope with new details
444
+ - Team Members
445
+ - John Doe (Senior Dev)
446
+ - Jane Smith (PM)
447
+ - New hire: Bob Wilson
448
+ - Updated Timeline
449
+ - Remove the old 'Deadlines' section"
450
+ ```
451
+
452
+ The tool will match existing blocks by content, update changed text, add new blocks, and remove deleted ones - all while keeping UIDs stable for blocks that still exist.
453
+
292
454
  ---
293
455
 
294
456
  ## Setup
@@ -71,10 +71,13 @@ Source:: https://example.com
71
71
  | `Step 1:: Do this thing` | `**Step 1:** Do this thing` | Step numbers are page-specific, not queryable concepts |
72
72
  | `Note:: Some observation` | Just write the text, or use `#note` | One-off labels don't need attribute syntax |
73
73
  | `Summary:: The main point` | `**Summary:** The main point` | Section headers are formatting, not metadata |
74
- | `Definition:: Some text` | `**Term**:: Definition` | Only use for actual definitions you want to query |
74
+ | `Definition:: Some text` | `Term:: Definition` | Only use for actual definitions you want to query |
75
+ | `Implementation Tier 3 (Societal Restructuring):: Some text` | `** Implementation Tier 3 (Societal Restructuring)**: Some text` | Label is specific to current concept |
75
76
 
76
77
  ⚠️ **The Test**: Ask yourself: "Will I ever query for all blocks with this attribute across my graph?" If no, use **bold formatting** (`**Label:**`) instead of `::` syntax.
77
78
 
79
+ NOTE: Never combine bold markdown formatting with `::`. Roam formats attributes in bold by default. ✅ `<attribute>::` ❌ `**<attribute>**::`
80
+
78
81
  ---
79
82
 
80
83
  ## Block Structures
@@ -328,7 +331,7 @@ Empty blocks and decorative dividers create clutter. Roam's outliner structure p
328
331
 
329
332
  ### Definitions
330
333
  ```
331
- **Term**:: Definition text #definition #[[domain]]
334
+ Term:: Definition text #definition #[[domain]]
332
335
  ```
333
336
 
334
337
  ### Questions for Future
@@ -456,6 +459,11 @@ When a tag would awkwardly affect sentence capitalization:
456
459
  [Cognitive biases]([[cognitive biases]]) affect decision-making...
457
460
  ```
458
461
 
462
+ ### Definitions (OVERRIDE)
463
+ ```
464
+ #def [[<term>]] : <definition>
465
+ ```
466
+
459
467
  ---
460
468
 
461
469
  ## Constraints & Guardrails
@@ -465,6 +473,7 @@ When a tag would awkwardly affect sentence capitalization:
465
473
  - **Tag obvious/redundant** — If parent block is tagged, children inherit context
466
474
  - **Use inconsistent capitalization** — Tags are lowercase unless proper nouns
467
475
  - **Create orphan tags** — Check if existing page/tag serves the purpose
476
+ - **Bold Attributes** - ❌ `**Attribute**::`, ✅ `Attribute::` (Roam auto-formats)
468
477
 
469
478
  ### DO
470
479
  - **Think retrieval-first** — How will you search for this later?
@@ -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
+ }