roam-research-mcp
Version:
MCP server and CLI for Roam Research
638 lines (629 loc) • 29.4 kB
JavaScript
import { Command } from 'commander';
import { existsSync, readFileSync } from 'fs';
import { basename } from 'path';
import { PageOperations } from '../../tools/operations/pages.js';
import { TodoOperations } from '../../tools/operations/todos.js';
import { BatchOperations } from '../../tools/operations/batch.js';
import { parseMarkdown, generateBlockUid, parseMarkdownHeadingLevel } from '../../markdown-utils.js';
import { printDebug, exitWithError } from '../utils/output.js';
import { resolveGraph } from '../utils/graph.js';
import { readStdin } from '../utils/input.js';
import { resolveParentRef, resolvePageRef } from '../utils/ref-resolver.js';
import { parentUidHint } from '../utils/ref-classifier.js';
import { uidExists } from '../../shared/page-validator.js';
import { formatRoamDate, sanitizeTagName } from '../../utils/helpers.js';
import { q, createPage as roamCreatePage } from '@roam-research/roam-api-sdk';
/**
* Flatten nested MarkdownNode[] to flat array with absolute levels
*/
function flattenNodes(nodes, baseLevel = 1) {
const result = [];
for (const node of nodes) {
result.push({
text: node.content,
level: baseLevel,
...(node.heading_level && { heading: node.heading_level }),
...(node.children_view_type === 'numbered' && { numbered_children: true })
});
if (node.children.length > 0) {
result.push(...flattenNodes(node.children, baseLevel + 1));
}
}
return result;
}
/**
* Infer hierarchy from heading levels when all blocks are at the same level.
* This handles prose-style markdown where headings (# ## ###) define structure
* without explicit indentation.
*
* Example: "# Title\n## Chapter\nContent" becomes:
* - Title (level 1, H1)
* - Chapter (level 2, H2)
* - Content (level 3)
*/
function adjustLevelsForHeadingHierarchy(blocks) {
if (blocks.length === 0)
return blocks;
// Only apply heading-based adjustment when:
// 1. All blocks are at the same level (no indentation-based hierarchy)
// 2. There are headings present
const allSameLevel = blocks.every(b => b.level === blocks[0].level);
const hasHeadings = blocks.some(b => b.heading);
if (!allSameLevel || !hasHeadings) {
// Indentation-based hierarchy exists, preserve it
return blocks;
}
const result = [];
// Track heading stack: each entry is { headingLevel: 1|2|3, adjustedLevel: number }
const headingStack = [];
for (const block of blocks) {
if (block.heading) {
// Pop headings of same or lower priority (higher h-number)
while (headingStack.length > 0 &&
headingStack[headingStack.length - 1].headingLevel >= block.heading) {
headingStack.pop();
}
// New heading level is one deeper than parent heading (or 1 if no parent)
const adjustedLevel = headingStack.length > 0
? headingStack[headingStack.length - 1].adjustedLevel + 1
: 1;
headingStack.push({ headingLevel: block.heading, adjustedLevel });
result.push({ ...block, level: adjustedLevel });
}
else {
// Content: nest under current heading context
const adjustedLevel = headingStack.length > 0
? headingStack[headingStack.length - 1].adjustedLevel + 1
: 1;
result.push({ ...block, level: adjustedLevel });
}
}
return result;
}
/**
* Check if content looks like a JSON array
*/
function looksLikeJsonArray(content) {
const trimmed = content.trim();
return trimmed.startsWith('[') && trimmed.endsWith(']');
}
/**
* Find or create a page by title, returns UID
*/
async function findOrCreatePage(graph, title) {
const findQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`;
const findResults = await q(graph, findQuery, [title]);
if (findResults && findResults.length > 0) {
return findResults[0][0];
}
// Create the page if it doesn't exist
await roamCreatePage(graph, {
action: 'create-page',
page: { title }
});
// Small delay for new page to be fully available as parent in Roam
await new Promise(resolve => setTimeout(resolve, 400));
const results = await q(graph, findQuery, [title]);
if (!results || results.length === 0) {
throw new Error(`Could not find created page: ${title}`);
}
return results[0][0];
}
/**
* Get or create today's daily page UID
*/
async function getDailyPageUid(graph) {
const today = new Date();
const dateStr = formatRoamDate(today);
return findOrCreatePage(graph, dateStr);
}
/**
* Find or create a heading block on a page. `created` tells the caller whether
* a block was added, so it can say so: a created parent is otherwise invisible.
*/
async function findOrCreateHeading(graph, pageUid, heading, headingLevel) {
// Search for existing heading block
const headingQuery = `[:find ?uid
:in $ ?page-uid ?text
:where
[?page :block/uid ?page-uid]
[?page :block/children ?block]
[?block :block/string ?text]
[?block :block/uid ?uid]]`;
const headingResults = await q(graph, headingQuery, [pageUid, heading]);
if (headingResults && headingResults.length > 0) {
return { uid: headingResults[0][0], created: false };
}
// Create the heading block
const batchOps = new BatchOperations(graph);
const headingUid = generateBlockUid();
await batchOps.processBatch([{
action: 'create-block',
location: { 'parent-uid': pageUid, order: 'last' },
string: heading,
uid: headingUid,
...(headingLevel && { heading: headingLevel })
}]);
return { uid: headingUid, created: true };
}
/**
* Parse JSON content blocks
*/
function parseJsonContent(content) {
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) {
throw new Error('JSON content must be an array of {text, level, heading?} objects');
}
return parsed.map((item, index) => {
if (typeof item.text !== 'string' || typeof item.level !== 'number') {
throw new Error(`Invalid item at index ${index}: must have "text" (string) and "level" (number)`);
}
// If heading not explicitly provided, detect from text and strip hashes
if (item.heading) {
return {
text: item.text,
level: item.level,
heading: item.heading
};
}
const { heading_level, content: strippedText } = parseMarkdownHeadingLevel(item.text);
return {
text: strippedText,
level: item.level,
...(heading_level > 0 && { heading: heading_level })
};
});
}
export function createSaveCommand() {
return new Command('save')
.description('Save text, files, or JSON to pages/blocks. Auto-detects format.')
.argument('[input]', 'Text, file path, or "-" for stdin (auto-detected)')
.option('--title <title>', 'Create a new page with this title')
.option('--update', 'Update existing page using smart diff (preserves block UIDs)')
.option('-p, --page <ref>', 'Target page by title (creates if missing), or by UID as ((uid)) (must exist). Default: daily page')
.option('--parent <text>', 'Nest under the block with this TEXT on the target page (creates if missing). Never a UID: to nest under a block by its UID, use --parent-uid. Use # prefix for heading level: "## Section"')
.option('--parent-uid <uid>', 'Nest under the block with this UID. Accepts uid or ((uid)). The block must exist, or nothing is written')
.option('-c, --categories <tags>', 'Comma-separated tags appended to first block')
.option('-t, --todo [text]', 'Add TODO item(s) to daily page. Accepts inline text or stdin')
.option('--json', 'Force JSON array format: [{text, level, heading?}, ...]')
.option('--flatten', 'Disable heading hierarchy inference (all blocks at root level)')
.option('--lines', 'Treat each non-empty line as a separate block (bypass markdown parsing)')
.option('--order <position>', 'Insertion position for top-level blocks: "first", "last", or integer (default: "last")')
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
.option('--write-key <key>', 'Write confirmation key (non-default graphs)')
.option('--debug', 'Show debug information')
.addHelpText('after', `
Choosing a parent block:
You know the block's UID -> --parent-uid <uid>
Content goes under that block. If no block has that UID, the command
fails and nothing is written. The block already has a page, so -p is
ignored.
You know the block's text -> --parent "<text>"
Content goes under the block with that text on the target page (the
daily page unless -p is given). The block is created if missing, and
stderr says so.
--parent is never a UID. --parent "((uid))" means a block containing that
reference, not the referenced block. Before 5.0 it meant the block itself.
Examples:
# Quick saves to daily page
roam save "Quick note" # Single block
roam save "# Important" -c "work,urgent" # H1 heading with tags
roam save --todo "Buy groceries" # TODO item
# Save under a parent block (see "Choosing a parent block" above)
roam save --parent-uid blockUid9 "Child" # Under the block with this UID
roam save --parent "## Notes" "My note" # Under H2 "Notes" (creates if missing)
roam save --parent "((blockUid9))" "Child" # Under a block CONTAINING that reference
# Target specific page
roam save -p "Project X" "Status update" # By title (creates if missing)
roam save -p "((pageUid12))" "Note" # By UID (must exist)
# File operations
roam save notes.md --title "My Notes" # Create page from file
roam save notes.md --title "My Notes" --update # Smart update (preserves UIDs)
cat data.json | roam save --json # Pipe JSON blocks
# Stdin operations
echo "Task from CLI" | roam save --todo # Pipe to TODO
cat note.md | roam save --title "From Pipe" # Pipe file content to new page
echo "Quick capture" | roam save -p "Inbox" # Pipe to specific page
pbpaste | roam save --lines --title "Clipboard" # Each line = separate block
cat list.txt | roam save --lines -p "Notes" # Lines to blocks on page
# Insertion order
roam save -p "Changelog" --order first "v2.18.0" # Prepend to top
roam save -p "Log" --order 1 "After first block" # Insert at position
# Combine options
roam save -p "Work" --parent "## Today" "Done with task" -c "wins"
JSON format (--json):
Array of blocks with text, level, and optional heading:
[
{"text": "# Main Title", "level": 1}, # Auto-detects H1
{"text": "Subheading", "level": 1, "heading": 2}, # Explicit H2
{"text": "Nested content", "level": 2}, # Child block
{"text": "Sibling", "level": 2}
]
`)
.action(async (input, options) => {
try {
// TODO mode: add a TODO item to today's daily page
if (options.todo !== undefined) {
let todoText;
if (typeof options.todo === 'string' && options.todo.length > 0) {
todoText = options.todo;
}
else {
if (process.stdin.isTTY) {
exitWithError('No TODO text. Use: roam save --todo "text" or echo "text" | roam save --todo');
}
todoText = (await readStdin()).trim();
}
if (!todoText) {
exitWithError('Empty TODO text');
}
const todos = todoText.split('\n').map(t => t.trim()).filter(Boolean);
if (options.debug) {
printDebug('TODO mode', true);
printDebug('Graph', options.graph || 'default');
printDebug('TODO items', todos);
}
const graph = resolveGraph(options, true);
const todoOps = new TodoOperations(graph);
const result = await todoOps.addTodos(todos);
if (result.success) {
console.log(`Added ${todos.length} TODO item(s) to today's daily page`);
}
else {
exitWithError('Failed to save TODO');
}
return;
}
// Determine content source: file, text argument, or stdin
let content;
let isFile = false;
let sourceFilename;
if (input && input !== '-') {
// Check if input is a file path that exists
if (existsSync(input)) {
isFile = true;
sourceFilename = input;
try {
content = readFileSync(input, 'utf-8');
}
catch (err) {
exitWithError(`Could not read file: ${input}`);
}
}
else {
// Treat as text content
content = input;
}
}
else {
// Read from stdin (or if input is explicit '-')
if (process.stdin.isTTY && input !== '-') {
exitWithError('No input. Use: roam save "text", roam save <file>, or pipe content');
}
content = await readStdin();
}
content = content.trim();
if (!content) {
exitWithError('Empty content');
}
// Determine format: JSON or markdown/text
const isJson = options.json || (isFile && sourceFilename?.endsWith('.json')) || looksLikeJsonArray(content);
// Parse content into blocks
let contentBlocks;
if (options.lines) {
// --lines mode: each non-empty line becomes a separate block, no markdown parsing
// Note: requires real newlines in input (use jq -r, not jq, for JSON string fields)
contentBlocks = content
.split('\n')
.map(line => line.trimEnd())
.filter(line => line.length > 0)
.map(line => ({ text: line, level: 1 }));
}
else if (isJson) {
try {
contentBlocks = parseJsonContent(content);
}
catch (err) {
exitWithError(err instanceof Error ? err.message : 'Invalid JSON');
}
}
else if (isFile || content.includes('\n')) {
// Multi-line content: parse as markdown
const nodes = parseMarkdown(content);
const flattened = flattenNodes(nodes);
// Apply heading hierarchy unless --flatten is specified
contentBlocks = options.flatten ? flattened : adjustLevelsForHeadingHierarchy(flattened);
}
else {
// Single line text: detect heading syntax and strip hashes
const { heading_level, content: strippedContent } = parseMarkdownHeadingLevel(content);
contentBlocks = [{
text: strippedContent,
level: 1,
...(heading_level > 0 && { heading: heading_level })
}];
}
if (contentBlocks.length === 0) {
exitWithError('No content blocks parsed');
}
// Parse categories
const categories = options.categories
? options.categories.split(',').map(c => c.trim()).filter(Boolean)
: undefined;
// Parse --order option
let orderValue = 'last';
if (options.order !== undefined) {
if (options.order === 'first') {
orderValue = 'first';
}
else if (options.order === 'last') {
orderValue = 'last';
}
else {
const parsed = parseInt(options.order, 10);
if (isNaN(parsed) || parsed < 0) {
exitWithError('--order must be "first", "last", or a non-negative integer');
}
orderValue = parsed;
}
}
if (options.debug) {
printDebug('Input', input || 'stdin');
printDebug('Is file', isFile);
printDebug('Is JSON', isJson);
printDebug('Lines mode', options.lines || false);
printDebug('Flatten mode', options.flatten || false);
printDebug('Order', orderValue);
printDebug('Graph', options.graph || 'default');
printDebug('Content blocks', contentBlocks.length);
printDebug('Parent', options.parentUid ?? options.parent ?? 'none');
printDebug('Target page', options.page || 'daily page');
printDebug('Categories', categories || 'none');
printDebug('Title', options.title || 'none');
// Show parsed blocks preview
console.error('\n[DEBUG] Parsed blocks:');
for (let i = 0; i < contentBlocks.length; i++) {
const b = contentBlocks[i];
const indent = ' '.repeat(b.level - 1);
const headingTag = b.heading ? ` [H${b.heading}]` : '';
const preview = b.text.length > 80 ? b.text.substring(0, 80) + '...' : b.text;
console.error(` ${i + 1}. ${indent}${preview}${headingTag}`);
}
console.error('');
}
const graph = resolveGraph(options, true);
// Resolve the parent before any write. --parent-uid is always a UID
// and must exist; --parent is always the text of the parent block.
const exists = (uid) => uidExists(graph, uid);
const parentTarget = await resolveParentRef({ parent: options.parent, parentUid: options.parentUid }, exists);
let parentUid;
let parentHeading;
let parentHeadingLevel;
if (parentTarget?.kind === 'uid') {
parentUid = parentTarget.uid;
}
else if (parentTarget) {
// Parse heading syntax from parent text
const { heading_level, content } = parseMarkdownHeadingLevel(parentTarget.text);
parentHeading = content;
if (heading_level > 0) {
parentHeadingLevel = heading_level;
}
}
if (options.debug) {
printDebug('Parent UID', parentUid || 'none');
printDebug('Parent heading', parentHeading || 'none');
}
// Determine operation mode based on options
const hasParent = parentUid || parentHeading;
const hasTitle = options.title;
const wantsPage = hasTitle && !hasParent;
if (wantsPage || (isFile && !hasParent && !options.page)) {
// PAGE MODE: create a page
const pageTitle = options.title || (sourceFilename ? basename(sourceFilename, '.md').replace('.json', '') : undefined);
if (!pageTitle) {
exitWithError('--title required for page creation from stdin');
}
const pageOps = new PageOperations(graph);
if (options.update) {
if (isJson) {
exitWithError('--update is not supported with JSON content');
}
const result = await pageOps.updatePageMarkdown(pageTitle, content, false);
if (result.success) {
console.log(`Updated page '${pageTitle}'`);
console.log(` ${result.summary}`);
if (result.preserved_uids.length > 0) {
console.log(` Preserved ${result.preserved_uids.length} block UID(s)`);
}
}
else {
exitWithError(`Failed to update page '${pageTitle}'`);
}
}
else {
const result = await pageOps.createPage(pageTitle, contentBlocks);
if (result.success) {
console.log(`Created page '${pageTitle}' (uid: ${result.page_uid})`);
}
else {
exitWithError(`Failed to create page '${pageTitle}'`);
}
}
return;
}
// BLOCK MODE: add content under parent or to daily page
if (parentUid) {
// Direct parent UID: use batch operations
const batchOps = new BatchOperations(graph);
// Build batch actions for all content blocks
const actions = [];
const uidMap = {};
for (let i = 0; i < contentBlocks.length; i++) {
const block = contentBlocks[i];
const uidPlaceholder = `block-${i}`;
uidMap[i] = uidPlaceholder;
// Determine parent for this block
let blockParent;
let blockOrder;
if (block.level === 1) {
blockParent = parentUid;
// Apply --order to top-level blocks only
if (typeof orderValue === 'number') {
blockOrder = orderValue + i;
}
else {
blockOrder = orderValue;
}
}
else {
// Find the closest ancestor at level - 1
let ancestorIndex = i - 1;
while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
ancestorIndex--;
}
if (ancestorIndex >= 0) {
blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
}
else {
blockParent = parentUid;
}
blockOrder = 'last';
}
actions.push({
action: 'create-block',
location: {
'parent-uid': blockParent,
order: blockOrder
},
string: block.text,
uid: `{{uid:${uidPlaceholder}}}`,
...(block.heading && { heading: block.heading }),
...(block.numbered_children && { 'children-view-type': 'numbered' })
});
}
const result = await batchOps.processBatch(actions);
if (result.success && result.uid_map) {
// Output the first block's UID
console.log(result.uid_map['block-0']);
}
else {
const errorMsg = typeof result.error === 'string'
? result.error
: result.error?.message || 'Unknown error';
exitWithError(`Failed to save: ${errorMsg}`);
}
return;
}
// Parent heading or target page: get target page UID first
let pageUid;
if (options.page) {
// ((uid)) must exist; a bare 9-character value is a UID only if the
// graph has it, otherwise a title ("Learnings"). [[ ]] is left alone.
const pageTarget = await resolvePageRef(options.page, exists);
pageUid = pageTarget.kind === 'uid'
? pageTarget.uid
: await findOrCreatePage(graph, pageTarget.title);
}
else {
pageUid = await getDailyPageUid(graph);
}
if (options.debug) {
printDebug('Target page UID', pageUid);
}
// Resolve heading to parent UID if specified
let targetParentUid;
if (parentHeading) {
const heading = await findOrCreateHeading(graph, pageUid, parentHeading, parentHeadingLevel);
targetParentUid = heading.uid;
if (heading.created) {
console.error(`Created parent block "${parentHeading}" (uid: ${heading.uid})`);
// Through 4.1.0 a UID here nested under that block. Say what
// happened instead, and how to get the other behaviour.
const hint = parentUidHint(options.parent ?? '');
if (hint)
console.error(hint);
}
}
else {
targetParentUid = pageUid;
}
// Format categories as Roam tags if provided (strip any pre-existing # or [[ ]] wrappers)
const categoryTags = categories?.map(raw => {
const cat = sanitizeTagName(raw);
return cat.includes(' ') ? `#[[${cat}]]` : `#${cat}`;
}).join(' ') || '';
// Create all blocks using batch operations
const batchOps = new BatchOperations(graph);
const actions = [];
const uidMap = {};
for (let i = 0; i < contentBlocks.length; i++) {
const block = contentBlocks[i];
const uidPlaceholder = `block-${i}`;
uidMap[i] = uidPlaceholder;
// Determine parent for this block
let blockParent;
let blockOrder;
if (block.level === 1) {
blockParent = targetParentUid;
// Apply --order to top-level blocks only
if (typeof orderValue === 'number') {
blockOrder = orderValue + i;
}
else {
blockOrder = orderValue;
}
}
else {
// Find the closest ancestor at level - 1
let ancestorIndex = i - 1;
while (ancestorIndex >= 0 && contentBlocks[ancestorIndex].level >= block.level) {
ancestorIndex--;
}
if (ancestorIndex >= 0) {
blockParent = `{{uid:${uidMap[ancestorIndex]}}}`;
}
else {
blockParent = targetParentUid;
}
blockOrder = 'last';
}
// Append category tags to first block only
const blockText = i === 0 && categoryTags
? `${block.text} ${categoryTags}`
: block.text;
actions.push({
action: 'create-block',
location: {
'parent-uid': blockParent,
order: blockOrder
},
string: blockText,
uid: `{{uid:${uidPlaceholder}}}`,
...(block.heading && { heading: block.heading }),
...(block.numbered_children && { 'children-view-type': 'numbered' })
});
}
const result = await batchOps.processBatch(actions);
if (result.success && result.uid_map) {
// Output first block UID (and parent if heading was used)
if (parentHeading) {
console.log(`${result.uid_map['block-0']} ${targetParentUid}`);
}
else {
console.log(result.uid_map['block-0']);
}
}
else {
const errorMsg = typeof result.error === 'string'
? result.error
: result.error?.message || 'Unknown error';
exitWithError(`Failed to save: ${errorMsg}`);
}
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithError(message);
}
});
}