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.
- package/README.md +360 -31
- package/build/Roam_Markdown_Cheatsheet.md +30 -12
- package/build/cli/batch/resolver.js +138 -0
- package/build/cli/batch/translator.js +363 -0
- package/build/cli/batch/types.js +4 -0
- package/build/cli/commands/batch.js +352 -0
- package/build/cli/commands/get.js +161 -0
- package/build/cli/commands/refs.js +135 -0
- package/build/cli/commands/rename.js +58 -0
- package/build/cli/commands/save.js +498 -0
- package/build/cli/commands/search.js +240 -0
- package/build/cli/commands/status.js +91 -0
- package/build/cli/commands/update.js +151 -0
- package/build/cli/roam.js +35 -0
- package/build/cli/utils/graph.js +56 -0
- package/build/cli/utils/output.js +122 -0
- package/build/config/environment.js +70 -34
- package/build/config/graph-registry.js +221 -0
- package/build/config/graph-registry.test.js +30 -0
- package/build/search/block-ref-search.js +34 -7
- package/build/search/status-search.js +5 -4
- package/build/server/roam-server.js +98 -53
- package/build/shared/validation.js +10 -5
- package/build/tools/helpers/refs.js +50 -31
- package/build/tools/operations/blocks.js +38 -1
- package/build/tools/operations/memory.js +51 -5
- package/build/tools/operations/pages.js +186 -111
- package/build/tools/operations/search/index.js +5 -1
- package/build/tools/operations/todos.js +1 -1
- package/build/tools/schemas.js +121 -41
- package/build/tools/tool-handlers.js +9 -2
- package/build/utils/helpers.js +22 -0
- package/package.json +11 -7
- package/build/cli/import-markdown.js +0 -98
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
3
|
+
import { formatSearchResults, printDebug, exitWithError } from '../utils/output.js';
|
|
4
|
+
import { resolveGraph } from '../utils/graph.js';
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a tag by stripping #, [[, ]] wrappers
|
|
7
|
+
*/
|
|
8
|
+
function normalizeTag(tag) {
|
|
9
|
+
return tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Check if content contains a tag (handles #tag, [[tag]], #[[tag]] formats)
|
|
13
|
+
*/
|
|
14
|
+
function contentHasTag(content, tag) {
|
|
15
|
+
const normalized = normalizeTag(tag);
|
|
16
|
+
return (content.includes(`[[${normalized}]]`) ||
|
|
17
|
+
content.includes(`#${normalized}`) ||
|
|
18
|
+
content.includes(`#[[${normalized}]]`));
|
|
19
|
+
}
|
|
20
|
+
export function createSearchCommand() {
|
|
21
|
+
return new Command('search')
|
|
22
|
+
.description('Search blocks by text, tags, Datalog queries, or within specific pages')
|
|
23
|
+
.argument('[terms...]', 'Search terms (multiple terms use AND logic)')
|
|
24
|
+
.option('--tag <tag>', 'Filter by tag (repeatable, comma-separated). Default: AND logic', (val, prev) => {
|
|
25
|
+
// Support both comma-separated and multiple flags
|
|
26
|
+
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
|
|
27
|
+
return prev ? [...prev, ...tags] : tags;
|
|
28
|
+
}, [])
|
|
29
|
+
.option('--any', 'Use OR logic for multiple tags (default is AND)')
|
|
30
|
+
.option('--negtag <tag>', 'Exclude blocks with tag (repeatable, comma-separated)', (val, prev) => {
|
|
31
|
+
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
|
|
32
|
+
return prev ? [...prev, ...tags] : tags;
|
|
33
|
+
}, [])
|
|
34
|
+
.option('--page <title>', 'Scope search to a specific page')
|
|
35
|
+
.option('-i, --case-insensitive', 'Case-insensitive search')
|
|
36
|
+
.option('-n, --limit <n>', 'Limit number of results (default: 20)', '20')
|
|
37
|
+
.option('--json', 'Output as JSON')
|
|
38
|
+
.option('--debug', 'Show query metadata')
|
|
39
|
+
.option('-g, --graph <name>', 'Target graph key (for multi-graph mode)')
|
|
40
|
+
.option('-q, --query <datalog>', 'Raw Datalog query (bypasses other search options)')
|
|
41
|
+
.option('--inputs <json>', 'JSON array of inputs for Datalog query')
|
|
42
|
+
.option('--regex <pattern>', 'Client-side regex filter on Datalog results')
|
|
43
|
+
.option('--regex-flags <flags>', 'Regex flags (e.g., "i" for case-insensitive)')
|
|
44
|
+
.addHelpText('after', `
|
|
45
|
+
Examples:
|
|
46
|
+
# Text search
|
|
47
|
+
roam search "meeting notes" # Find blocks containing text
|
|
48
|
+
roam search api integration # Multiple terms (AND logic)
|
|
49
|
+
roam search "bug fix" -i # Case-insensitive search
|
|
50
|
+
|
|
51
|
+
# Tag search
|
|
52
|
+
roam search --tag TODO # All blocks with #TODO
|
|
53
|
+
roam search --tag "[[Project Alpha]]" # Blocks with page reference
|
|
54
|
+
roam search --tag work --page "January 3rd, 2026" # Tag on specific page
|
|
55
|
+
|
|
56
|
+
# Multiple tags
|
|
57
|
+
roam search --tag TODO --tag urgent # Blocks with BOTH tags (AND)
|
|
58
|
+
roam search --tag "TODO,urgent,blocked" # Comma-separated (AND)
|
|
59
|
+
roam search --tag TODO --tag urgent --any # Blocks with ANY tag (OR)
|
|
60
|
+
|
|
61
|
+
# Exclude tags
|
|
62
|
+
roam search --tag TODO --negtag done # TODOs excluding #done
|
|
63
|
+
roam search --tag TODO --negtag "someday,maybe" # Exclude multiple
|
|
64
|
+
|
|
65
|
+
# Combined filters
|
|
66
|
+
roam search urgent --tag TODO # Text + tag filter
|
|
67
|
+
roam search "review" --page "Work" # Search within page
|
|
68
|
+
|
|
69
|
+
# Output options
|
|
70
|
+
roam search "design" -n 50 # Limit to 50 results
|
|
71
|
+
roam search "api" --json # JSON output
|
|
72
|
+
|
|
73
|
+
# Datalog queries (advanced)
|
|
74
|
+
roam search -q '[:find ?title :where [?e :node/title ?title]]'
|
|
75
|
+
roam search -q '[:find ?s :in $ ?term :where [?b :block/string ?s] [(clojure.string/includes? ?s ?term)]]' --inputs '["TODO"]'
|
|
76
|
+
roam search -q '[:find ?uid ?s :where [?b :block/uid ?uid] [?b :block/string ?s]]' --regex "meeting" --regex-flags "i"
|
|
77
|
+
|
|
78
|
+
Datalog tips:
|
|
79
|
+
Common attributes: :node/title, :block/string, :block/uid, :block/page, :block/children
|
|
80
|
+
Predicates: clojure.string/includes?, clojure.string/starts-with?, <, >, =
|
|
81
|
+
`)
|
|
82
|
+
.action(async (terms, options) => {
|
|
83
|
+
try {
|
|
84
|
+
const graph = resolveGraph(options, false);
|
|
85
|
+
const limit = parseInt(options.limit || '20', 10);
|
|
86
|
+
const outputOptions = {
|
|
87
|
+
json: options.json,
|
|
88
|
+
debug: options.debug
|
|
89
|
+
};
|
|
90
|
+
if (options.debug) {
|
|
91
|
+
printDebug('Search terms', terms);
|
|
92
|
+
printDebug('Graph', options.graph || 'default');
|
|
93
|
+
printDebug('Options', options);
|
|
94
|
+
}
|
|
95
|
+
const searchOps = new SearchOperations(graph);
|
|
96
|
+
// Datalog query mode (bypasses other search options)
|
|
97
|
+
// See for query construction - Roam_Research_Datalog_Cheatsheet.md
|
|
98
|
+
if (options.query) {
|
|
99
|
+
// Parse inputs if provided
|
|
100
|
+
let inputs;
|
|
101
|
+
if (options.inputs) {
|
|
102
|
+
try {
|
|
103
|
+
inputs = JSON.parse(options.inputs);
|
|
104
|
+
if (!Array.isArray(inputs)) {
|
|
105
|
+
exitWithError('--inputs must be a JSON array');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
exitWithError('Invalid JSON in --inputs');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (options.debug) {
|
|
113
|
+
printDebug('Datalog query', options.query);
|
|
114
|
+
printDebug('Inputs', inputs || 'none');
|
|
115
|
+
printDebug('Regex filter', options.regex || 'none');
|
|
116
|
+
}
|
|
117
|
+
const result = await searchOps.executeDatomicQuery({
|
|
118
|
+
query: options.query,
|
|
119
|
+
inputs,
|
|
120
|
+
regexFilter: options.regex,
|
|
121
|
+
regexFlags: options.regexFlags
|
|
122
|
+
});
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
exitWithError(result.message || 'Query failed');
|
|
125
|
+
}
|
|
126
|
+
// Apply limit and format output
|
|
127
|
+
const limitedMatches = result.matches.slice(0, limit);
|
|
128
|
+
if (options.json) {
|
|
129
|
+
// For JSON output, parse the content back to objects
|
|
130
|
+
const parsed = limitedMatches.map(m => {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(m.content);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return m.content;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// For text output, show raw results
|
|
142
|
+
if (limitedMatches.length === 0) {
|
|
143
|
+
console.log('No results found.');
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
console.log(`Found ${result.matches.length} results${result.matches.length > limit ? ` (showing first ${limit})` : ''}:\n`);
|
|
147
|
+
for (const match of limitedMatches) {
|
|
148
|
+
console.log(match.content);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Determine search type based on options
|
|
155
|
+
const tags = options.tag || [];
|
|
156
|
+
if (tags.length > 0 && terms.length === 0) {
|
|
157
|
+
// Tag-only search
|
|
158
|
+
const normalizedTags = tags.map(normalizeTag);
|
|
159
|
+
const useOrLogic = options.any || false;
|
|
160
|
+
if (options.debug) {
|
|
161
|
+
printDebug('Tag search', {
|
|
162
|
+
tags: normalizedTags,
|
|
163
|
+
logic: useOrLogic ? 'OR' : 'AND',
|
|
164
|
+
page: options.page
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Search for first tag, then filter by additional tags
|
|
168
|
+
const result = await searchOps.searchForTag(normalizedTags[0], options.page);
|
|
169
|
+
let matches = result.matches;
|
|
170
|
+
// Apply multi-tag filter if more than one tag
|
|
171
|
+
if (normalizedTags.length > 1) {
|
|
172
|
+
matches = matches.filter(m => {
|
|
173
|
+
if (useOrLogic) {
|
|
174
|
+
// OR: has at least one tag
|
|
175
|
+
return normalizedTags.some(tag => contentHasTag(m.content, tag));
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// AND: has all tags
|
|
179
|
+
return normalizedTags.every(tag => contentHasTag(m.content, tag));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// Apply negtag filter (exclude blocks with any of these tags)
|
|
184
|
+
const negTags = options.negtag || [];
|
|
185
|
+
if (negTags.length > 0) {
|
|
186
|
+
const normalizedNegTags = negTags.map(normalizeTag);
|
|
187
|
+
matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag)));
|
|
188
|
+
}
|
|
189
|
+
const limitedMatches = matches.slice(0, limit);
|
|
190
|
+
console.log(formatSearchResults(limitedMatches, outputOptions));
|
|
191
|
+
}
|
|
192
|
+
else if (terms.length > 0) {
|
|
193
|
+
// Text search (with optional tag filter)
|
|
194
|
+
const searchText = terms.join(' ');
|
|
195
|
+
if (options.debug) {
|
|
196
|
+
printDebug('Text search', { text: searchText, page: options.page, tag: options.tag });
|
|
197
|
+
}
|
|
198
|
+
const result = await searchOps.searchByText({
|
|
199
|
+
text: searchText,
|
|
200
|
+
page_title_uid: options.page
|
|
201
|
+
});
|
|
202
|
+
// Apply client-side filters
|
|
203
|
+
let matches = result.matches;
|
|
204
|
+
// Case-insensitive filter if requested
|
|
205
|
+
if (options.caseInsensitive) {
|
|
206
|
+
const lowerSearchText = searchText.toLowerCase();
|
|
207
|
+
matches = matches.filter(m => m.content.toLowerCase().includes(lowerSearchText));
|
|
208
|
+
}
|
|
209
|
+
// Tag filter if provided
|
|
210
|
+
if (tags.length > 0) {
|
|
211
|
+
const normalizedTags = tags.map(normalizeTag);
|
|
212
|
+
const useOrLogic = options.any || false;
|
|
213
|
+
matches = matches.filter(m => {
|
|
214
|
+
if (useOrLogic) {
|
|
215
|
+
return normalizedTags.some(tag => contentHasTag(m.content, tag));
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
return normalizedTags.every(tag => contentHasTag(m.content, tag));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// Negtag filter (exclude blocks with any of these tags)
|
|
223
|
+
const negTags = options.negtag || [];
|
|
224
|
+
if (negTags.length > 0) {
|
|
225
|
+
const normalizedNegTags = negTags.map(normalizeTag);
|
|
226
|
+
matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag)));
|
|
227
|
+
}
|
|
228
|
+
// Apply limit
|
|
229
|
+
console.log(formatSearchResults(matches.slice(0, limit), outputOptions));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
exitWithError('Please provide search terms or use --tag to search by tag');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
237
|
+
exitWithError(message);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getRegistry } from '../utils/graph.js';
|
|
6
|
+
import { q } from '@roam-research/roam-api-sdk';
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
// Read package.json to get the version
|
|
10
|
+
const packageJsonPath = join(__dirname, '../../../package.json');
|
|
11
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
12
|
+
const version = packageJson.version;
|
|
13
|
+
export function createStatusCommand() {
|
|
14
|
+
return new Command('status')
|
|
15
|
+
.description('Show available graphs and connection status')
|
|
16
|
+
.option('--ping', 'Test connection to each graph')
|
|
17
|
+
.option('--json', 'Output as JSON')
|
|
18
|
+
.addHelpText('after', `
|
|
19
|
+
Examples:
|
|
20
|
+
# Show available graphs
|
|
21
|
+
roam status
|
|
22
|
+
|
|
23
|
+
# Test connectivity to all graphs
|
|
24
|
+
roam status --ping
|
|
25
|
+
|
|
26
|
+
# JSON output for scripting
|
|
27
|
+
roam status --json
|
|
28
|
+
`)
|
|
29
|
+
.action(async (options) => {
|
|
30
|
+
try {
|
|
31
|
+
const registry = getRegistry();
|
|
32
|
+
const graphKeys = registry.getAvailableGraphs();
|
|
33
|
+
const statuses = [];
|
|
34
|
+
for (const key of graphKeys) {
|
|
35
|
+
const config = registry.getConfig(key);
|
|
36
|
+
const isDefault = key === registry.defaultKey;
|
|
37
|
+
const isProtected = !!config.write_key;
|
|
38
|
+
const status = {
|
|
39
|
+
name: key,
|
|
40
|
+
default: isDefault,
|
|
41
|
+
protected: isProtected,
|
|
42
|
+
};
|
|
43
|
+
if (isProtected) {
|
|
44
|
+
status.writeKey = config.write_key;
|
|
45
|
+
}
|
|
46
|
+
if (options.ping) {
|
|
47
|
+
try {
|
|
48
|
+
const graph = registry.getGraph(key);
|
|
49
|
+
// Simple query to test connection - just find any entity
|
|
50
|
+
await q(graph, '[:find ?e . :where [?e :db/id]]', []);
|
|
51
|
+
status.connected = true;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
status.connected = false;
|
|
55
|
+
status.error = error instanceof Error ? error.message : String(error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
statuses.push(status);
|
|
59
|
+
}
|
|
60
|
+
if (options.json) {
|
|
61
|
+
console.log(JSON.stringify({
|
|
62
|
+
version,
|
|
63
|
+
graphs: statuses,
|
|
64
|
+
}, null, 2));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Pretty print
|
|
68
|
+
console.log(`Roam Research MCP v${version}\n`);
|
|
69
|
+
console.log('Graphs:');
|
|
70
|
+
for (const status of statuses) {
|
|
71
|
+
const defaultTag = status.default ? ' (default)' : '';
|
|
72
|
+
const protectedTag = status.protected ? ' [protected]' : '';
|
|
73
|
+
let connectionStatus = '';
|
|
74
|
+
if (options.ping) {
|
|
75
|
+
connectionStatus = status.connected
|
|
76
|
+
? ' ✓ connected'
|
|
77
|
+
: ` ✗ ${status.error || 'connection failed'}`;
|
|
78
|
+
}
|
|
79
|
+
console.log(` • ${status.name}${defaultTag}${protectedTag}${connectionStatus}`);
|
|
80
|
+
}
|
|
81
|
+
if (statuses.some(s => s.protected)) {
|
|
82
|
+
console.log('\nWrite-protected graphs require --write-key flag for modifications.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
console.error(`Error: ${message}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { BatchOperations } from '../../tools/operations/batch.js';
|
|
3
|
+
import { parseMarkdownHeadingLevel } from '../../markdown-utils.js';
|
|
4
|
+
import { printDebug, exitWithError } from '../utils/output.js';
|
|
5
|
+
import { resolveGraph } from '../utils/graph.js';
|
|
6
|
+
// Patterns for TODO/DONE markers (both {{TODO}} and {{[[TODO]]}} formats)
|
|
7
|
+
const TODO_PATTERN = /\{\{(\[\[)?TODO(\]\])?\}\}\s*/g;
|
|
8
|
+
const DONE_PATTERN = /\{\{(\[\[)?DONE(\]\])?\}\}\s*/g;
|
|
9
|
+
const ANY_STATUS_PATTERN = /\{\{(\[\[)?(TODO|DONE)(\]\])?\}\}\s*/g;
|
|
10
|
+
/**
|
|
11
|
+
* Apply TODO/DONE status to content
|
|
12
|
+
* - If target status marker exists, no change needed
|
|
13
|
+
* - If opposite status exists, replace it
|
|
14
|
+
* - If no status exists, prepend
|
|
15
|
+
*/
|
|
16
|
+
function applyStatus(content, status) {
|
|
17
|
+
const marker = `{{[[${status}]]}} `;
|
|
18
|
+
const hasStatus = status === 'TODO'
|
|
19
|
+
? TODO_PATTERN.test(content)
|
|
20
|
+
: DONE_PATTERN.test(content);
|
|
21
|
+
// Reset regex lastIndex
|
|
22
|
+
TODO_PATTERN.lastIndex = 0;
|
|
23
|
+
DONE_PATTERN.lastIndex = 0;
|
|
24
|
+
if (hasStatus) {
|
|
25
|
+
return content; // Already has the target status
|
|
26
|
+
}
|
|
27
|
+
// Check for opposite status and replace
|
|
28
|
+
const oppositePattern = status === 'TODO' ? DONE_PATTERN : TODO_PATTERN;
|
|
29
|
+
if (oppositePattern.test(content)) {
|
|
30
|
+
oppositePattern.lastIndex = 0;
|
|
31
|
+
return content.replace(oppositePattern, marker);
|
|
32
|
+
}
|
|
33
|
+
// No status exists, prepend
|
|
34
|
+
return marker + content;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Remove any TODO/DONE status from content
|
|
38
|
+
*/
|
|
39
|
+
function clearStatus(content) {
|
|
40
|
+
return content.replace(ANY_STATUS_PATTERN, '').trim();
|
|
41
|
+
}
|
|
42
|
+
export function createUpdateCommand() {
|
|
43
|
+
return new Command('update')
|
|
44
|
+
.description('Update block content, heading, open/closed state, or TODO/DONE status')
|
|
45
|
+
.argument('<uid>', 'Block UID to update (accepts ((uid)) wrapper)')
|
|
46
|
+
.argument('<content>', 'New content. Use # prefix for heading: "# Title" sets H1')
|
|
47
|
+
.option('-H, --heading <level>', 'Set heading level (1-3), or 0 to remove')
|
|
48
|
+
.option('-o, --open', 'Expand block (show children)')
|
|
49
|
+
.option('-c, --closed', 'Collapse block (hide children)')
|
|
50
|
+
.option('-T, --todo', 'Set as TODO (replaces DONE if present, prepends if none)')
|
|
51
|
+
.option('-D, --done', 'Set as DONE (replaces TODO if present, prepends if none)')
|
|
52
|
+
.option('--clear-status', 'Remove TODO/DONE marker')
|
|
53
|
+
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
54
|
+
.option('--write-key <key>', 'Write confirmation key (non-default graphs)')
|
|
55
|
+
.option('--debug', 'Show debug information')
|
|
56
|
+
.addHelpText('after', `
|
|
57
|
+
Examples:
|
|
58
|
+
# Basic update
|
|
59
|
+
roam update abc123def "New content" # Update block text
|
|
60
|
+
roam update "((abc123def))" "New content" # UID with wrapper
|
|
61
|
+
|
|
62
|
+
# Heading updates
|
|
63
|
+
roam update abc123def "# Main Title" # Auto-detect H1, strip #
|
|
64
|
+
roam update abc123def "Title" -H 2 # Explicit H2
|
|
65
|
+
roam update abc123def "Plain text" -H 0 # Remove heading
|
|
66
|
+
|
|
67
|
+
# Block state
|
|
68
|
+
roam update abc123def "Content" -o # Expand block
|
|
69
|
+
roam update abc123def "Content" -c # Collapse block
|
|
70
|
+
|
|
71
|
+
# TODO/DONE status
|
|
72
|
+
roam update abc123def "Task" -T # Set as TODO
|
|
73
|
+
roam update abc123def "Task" -D # Mark as DONE
|
|
74
|
+
roam update abc123def "Task" --clear-status # Remove status marker
|
|
75
|
+
`)
|
|
76
|
+
.action(async (uid, content, options) => {
|
|
77
|
+
try {
|
|
78
|
+
// Strip (( )) wrapper if present
|
|
79
|
+
const blockUid = uid.replace(/^\(\(|\)\)$/g, '');
|
|
80
|
+
// Detect heading from content unless explicitly set
|
|
81
|
+
let finalContent = content;
|
|
82
|
+
let headingLevel;
|
|
83
|
+
if (options.heading !== undefined) {
|
|
84
|
+
// Explicit heading option takes precedence
|
|
85
|
+
const level = parseInt(options.heading, 10);
|
|
86
|
+
if (level >= 0 && level <= 3) {
|
|
87
|
+
headingLevel = level === 0 ? undefined : level;
|
|
88
|
+
// Still strip # prefix if present for consistency
|
|
89
|
+
const { content: stripped } = parseMarkdownHeadingLevel(content);
|
|
90
|
+
finalContent = stripped;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Auto-detect heading from content
|
|
95
|
+
const { heading_level, content: stripped } = parseMarkdownHeadingLevel(content);
|
|
96
|
+
if (heading_level > 0) {
|
|
97
|
+
headingLevel = heading_level;
|
|
98
|
+
finalContent = stripped;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Handle open/closed state
|
|
102
|
+
let openState;
|
|
103
|
+
if (options.open) {
|
|
104
|
+
openState = true;
|
|
105
|
+
}
|
|
106
|
+
else if (options.closed) {
|
|
107
|
+
openState = false;
|
|
108
|
+
}
|
|
109
|
+
// Handle TODO/DONE status
|
|
110
|
+
if (options.clearStatus) {
|
|
111
|
+
finalContent = clearStatus(finalContent);
|
|
112
|
+
}
|
|
113
|
+
else if (options.todo) {
|
|
114
|
+
finalContent = applyStatus(finalContent, 'TODO');
|
|
115
|
+
}
|
|
116
|
+
else if (options.done) {
|
|
117
|
+
finalContent = applyStatus(finalContent, 'DONE');
|
|
118
|
+
}
|
|
119
|
+
if (options.debug) {
|
|
120
|
+
printDebug('Block UID', blockUid);
|
|
121
|
+
printDebug('Graph', options.graph || 'default');
|
|
122
|
+
printDebug('Content', finalContent);
|
|
123
|
+
printDebug('Heading level', headingLevel ?? 'none');
|
|
124
|
+
printDebug('Open state', openState ?? 'unchanged');
|
|
125
|
+
printDebug('Status', options.todo ? 'TODO' : options.done ? 'DONE' : options.clearStatus ? 'cleared' : 'unchanged');
|
|
126
|
+
}
|
|
127
|
+
const graph = resolveGraph(options, true); // This is a write operation
|
|
128
|
+
const batchOps = new BatchOperations(graph);
|
|
129
|
+
const result = await batchOps.processBatch([{
|
|
130
|
+
action: 'update-block',
|
|
131
|
+
uid: blockUid,
|
|
132
|
+
string: finalContent,
|
|
133
|
+
...(headingLevel !== undefined && { heading: headingLevel }),
|
|
134
|
+
...(openState !== undefined && { open: openState })
|
|
135
|
+
}]);
|
|
136
|
+
if (result.success) {
|
|
137
|
+
console.log(`Updated block ${blockUid}`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const errorMsg = typeof result.error === 'string'
|
|
141
|
+
? result.error
|
|
142
|
+
: result.error?.message || 'Unknown error';
|
|
143
|
+
exitWithError(`Failed to update block: ${errorMsg}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
exitWithError(message);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createGetCommand } from './commands/get.js';
|
|
7
|
+
import { createSearchCommand } from './commands/search.js';
|
|
8
|
+
import { createSaveCommand } from './commands/save.js';
|
|
9
|
+
import { createRefsCommand } from './commands/refs.js';
|
|
10
|
+
import { createUpdateCommand } from './commands/update.js';
|
|
11
|
+
import { createBatchCommand } from './commands/batch.js';
|
|
12
|
+
import { createRenameCommand } from './commands/rename.js';
|
|
13
|
+
import { createStatusCommand } from './commands/status.js';
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = dirname(__filename);
|
|
16
|
+
// Read package.json to get the version
|
|
17
|
+
const packageJsonPath = join(__dirname, '../../package.json');
|
|
18
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
19
|
+
const cliVersion = packageJson.version;
|
|
20
|
+
const program = new Command();
|
|
21
|
+
program
|
|
22
|
+
.name('roam')
|
|
23
|
+
.description('CLI for Roam Research')
|
|
24
|
+
.version(cliVersion);
|
|
25
|
+
// Register subcommands
|
|
26
|
+
program.addCommand(createGetCommand());
|
|
27
|
+
program.addCommand(createSearchCommand());
|
|
28
|
+
program.addCommand(createSaveCommand());
|
|
29
|
+
program.addCommand(createRefsCommand());
|
|
30
|
+
program.addCommand(createUpdateCommand());
|
|
31
|
+
program.addCommand(createBatchCommand());
|
|
32
|
+
program.addCommand(createRenameCommand());
|
|
33
|
+
program.addCommand(createStatusCommand());
|
|
34
|
+
// Parse arguments
|
|
35
|
+
program.parse();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI graph resolution utilities for multi-graph support
|
|
3
|
+
*/
|
|
4
|
+
import { createRegistryFromEnv } from '../../config/graph-registry.js';
|
|
5
|
+
import { validateEnvironment } from '../../config/environment.js';
|
|
6
|
+
let registry = null;
|
|
7
|
+
/**
|
|
8
|
+
* Get or create the GraphRegistry singleton
|
|
9
|
+
*/
|
|
10
|
+
export function getRegistry() {
|
|
11
|
+
if (!registry) {
|
|
12
|
+
validateEnvironment();
|
|
13
|
+
registry = createRegistryFromEnv();
|
|
14
|
+
}
|
|
15
|
+
return registry;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a Graph instance for CLI use
|
|
19
|
+
* Validates write access for write operations
|
|
20
|
+
*
|
|
21
|
+
* @param options - CLI options containing graph and writeKey
|
|
22
|
+
* @param isWriteOp - Whether this is a write operation
|
|
23
|
+
*/
|
|
24
|
+
export function resolveGraph(options, isWriteOp = false) {
|
|
25
|
+
const reg = getRegistry();
|
|
26
|
+
if (isWriteOp) {
|
|
27
|
+
// For write operations, validate write access
|
|
28
|
+
const graphKey = options.graph ?? reg.defaultKey;
|
|
29
|
+
if (!reg.isWriteAllowed(graphKey, options.writeKey)) {
|
|
30
|
+
const config = reg.getConfig(graphKey);
|
|
31
|
+
if (config?.write_key) {
|
|
32
|
+
throw new Error(`Write to "${graphKey}" graph requires --write-key confirmation.\n` +
|
|
33
|
+
`Use: --write-key "${config.write_key}"`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return reg.getGraph(options.graph);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get available graph names for help text
|
|
41
|
+
*/
|
|
42
|
+
export function getAvailableGraphs() {
|
|
43
|
+
return getRegistry().getAvailableGraphs();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get the default graph key
|
|
47
|
+
*/
|
|
48
|
+
export function getDefaultGraphKey() {
|
|
49
|
+
return getRegistry().defaultKey;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Check if running in multi-graph mode
|
|
53
|
+
*/
|
|
54
|
+
export function isMultiGraphMode() {
|
|
55
|
+
return getRegistry().isMultiGraph;
|
|
56
|
+
}
|