roam-research-mcp 1.6.0 → 2.4.3
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 +202 -13
- package/build/Roam_Markdown_Cheatsheet.md +116 -269
- 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 +345 -0
- package/build/cli/commands/get.js +156 -43
- package/build/cli/commands/refs.js +63 -32
- package/build/cli/commands/rename.js +58 -0
- package/build/cli/commands/save.js +436 -63
- package/build/cli/commands/search.js +152 -31
- package/build/cli/commands/status.js +91 -0
- package/build/cli/commands/update.js +194 -0
- package/build/cli/roam.js +18 -1
- package/build/cli/utils/graph.js +56 -0
- package/build/cli/utils/input.js +10 -0
- package/build/cli/utils/output.js +34 -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/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 +59 -9
- 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 +123 -42
- package/build/tools/tool-handlers.js +9 -2
- package/build/utils/helpers.js +22 -0
- package/package.json +8 -5
|
@@ -1,70 +1,191 @@
|
|
|
1
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
2
|
import { SearchOperations } from '../../tools/operations/search/index.js';
|
|
5
3
|
import { formatSearchResults, printDebug, exitWithError } from '../utils/output.js';
|
|
4
|
+
import { resolveGraph } from '../utils/graph.js';
|
|
5
|
+
import { readStdin } from '../utils/input.js';
|
|
6
|
+
/**
|
|
7
|
+
* Normalize a tag by stripping #, [[, ]] wrappers
|
|
8
|
+
*/
|
|
9
|
+
function normalizeTag(tag) {
|
|
10
|
+
return tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Check if content contains a tag (handles #tag, [[tag]], #[[tag]] formats)
|
|
14
|
+
*/
|
|
15
|
+
function contentHasTag(content, tag) {
|
|
16
|
+
const normalized = normalizeTag(tag);
|
|
17
|
+
return (content.includes(`[[${normalized}]]`) ||
|
|
18
|
+
content.includes(`#${normalized}`) ||
|
|
19
|
+
content.includes(`#[[${normalized}]]`));
|
|
20
|
+
}
|
|
6
21
|
export function createSearchCommand() {
|
|
7
22
|
return new Command('search')
|
|
8
|
-
.description('Search
|
|
9
|
-
.argument('[terms...]', 'Search terms (multiple terms use AND logic)')
|
|
10
|
-
.option('--tag <tag>', 'Filter by tag (
|
|
23
|
+
.description('Search blocks by text, tags, Datalog queries, or within specific pages')
|
|
24
|
+
.argument('[terms...]', 'Search terms (multiple terms use AND logic). Reads from stdin if omitted.')
|
|
25
|
+
.option('--tag <tag>', 'Filter by tag (repeatable, comma-separated). Default: AND logic', (val, prev) => {
|
|
26
|
+
// Support both comma-separated and multiple flags
|
|
27
|
+
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
|
|
28
|
+
return prev ? [...prev, ...tags] : tags;
|
|
29
|
+
}, [])
|
|
30
|
+
.option('--any', 'Use OR logic for multiple tags (default is AND)')
|
|
31
|
+
.option('--negtag <tag>', 'Exclude blocks with tag (repeatable, comma-separated)', (val, prev) => {
|
|
32
|
+
const tags = val.split(',').map(t => t.trim()).filter(Boolean);
|
|
33
|
+
return prev ? [...prev, ...tags] : tags;
|
|
34
|
+
}, [])
|
|
11
35
|
.option('--page <title>', 'Scope search to a specific page')
|
|
12
36
|
.option('-i, --case-insensitive', 'Case-insensitive search')
|
|
13
37
|
.option('-n, --limit <n>', 'Limit number of results (default: 20)', '20')
|
|
14
38
|
.option('--json', 'Output as JSON')
|
|
15
39
|
.option('--debug', 'Show query metadata')
|
|
40
|
+
.option('-g, --graph <name>', 'Target graph key (for multi-graph mode)')
|
|
41
|
+
.option('-q, --query <datalog>', 'Raw Datalog query (bypasses other search options)')
|
|
42
|
+
.option('--inputs <json>', 'JSON array of inputs for Datalog query')
|
|
43
|
+
.option('--regex <pattern>', 'Client-side regex filter on Datalog results')
|
|
44
|
+
.option('--regex-flags <flags>', 'Regex flags (e.g., "i" for case-insensitive)')
|
|
45
|
+
.addHelpText('after', `
|
|
46
|
+
Examples:
|
|
47
|
+
# Text search
|
|
48
|
+
roam search "meeting notes" # Find blocks containing text
|
|
49
|
+
roam search api integration # Multiple terms (AND logic)
|
|
50
|
+
|
|
51
|
+
# Stdin search
|
|
52
|
+
echo "urgent project" | roam search # Pipe terms
|
|
53
|
+
roam get today | roam search TODO # Search within output
|
|
54
|
+
|
|
55
|
+
# Tag search
|
|
56
|
+
roam search --tag TODO # All blocks with #TODO
|
|
57
|
+
roam search --tag "[[Project Alpha]]" # Blocks with page reference
|
|
58
|
+
|
|
59
|
+
# Datalog queries (advanced)
|
|
60
|
+
roam search -q '[:find ?uid ?s :where [?b :block/uid ?uid] [?b :block/string ?s]]' --regex "meeting"
|
|
61
|
+
`)
|
|
16
62
|
.action(async (terms, options) => {
|
|
17
63
|
try {
|
|
18
|
-
const graph =
|
|
19
|
-
token: API_TOKEN,
|
|
20
|
-
graph: GRAPH_NAME
|
|
21
|
-
});
|
|
64
|
+
const graph = resolveGraph(options, false);
|
|
22
65
|
const limit = parseInt(options.limit || '20', 10);
|
|
23
66
|
const outputOptions = {
|
|
24
67
|
json: options.json,
|
|
25
68
|
debug: options.debug
|
|
26
69
|
};
|
|
70
|
+
let searchTerms = terms;
|
|
71
|
+
// If no terms provided as args, try stdin
|
|
72
|
+
if (searchTerms.length === 0 && !process.stdin.isTTY && !options.query && (options.tag?.length === 0)) {
|
|
73
|
+
const input = await readStdin();
|
|
74
|
+
if (input) {
|
|
75
|
+
searchTerms = input.trim().split(/\s+/);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
27
78
|
if (options.debug) {
|
|
28
|
-
printDebug('Search terms',
|
|
79
|
+
printDebug('Search terms', searchTerms);
|
|
80
|
+
printDebug('Graph', options.graph || 'default');
|
|
29
81
|
printDebug('Options', options);
|
|
30
82
|
}
|
|
31
83
|
const searchOps = new SearchOperations(graph);
|
|
84
|
+
// Datalog query mode (bypasses other search options)
|
|
85
|
+
if (options.query) {
|
|
86
|
+
// Parse inputs if provided
|
|
87
|
+
let inputs;
|
|
88
|
+
if (options.inputs) {
|
|
89
|
+
try {
|
|
90
|
+
inputs = JSON.parse(options.inputs);
|
|
91
|
+
if (!Array.isArray(inputs)) {
|
|
92
|
+
exitWithError('--inputs must be a JSON array');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
exitWithError('Invalid JSON in --inputs');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const result = await searchOps.executeDatomicQuery({
|
|
100
|
+
query: options.query,
|
|
101
|
+
inputs,
|
|
102
|
+
regexFilter: options.regex,
|
|
103
|
+
regexFlags: options.regexFlags
|
|
104
|
+
});
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
exitWithError(result.message || 'Query failed');
|
|
107
|
+
}
|
|
108
|
+
// Apply limit and format output
|
|
109
|
+
const limitedMatches = result.matches.slice(0, limit);
|
|
110
|
+
if (options.json) {
|
|
111
|
+
const parsed = limitedMatches.map(m => {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(m.content);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return m.content;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
if (limitedMatches.length === 0) {
|
|
123
|
+
console.log('No results found.');
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.log(`Found ${result.matches.length} results${result.matches.length > limit ? ` (showing first ${limit})` : ''}:\n`);
|
|
127
|
+
for (const match of limitedMatches) {
|
|
128
|
+
console.log(match.content);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
32
134
|
// Determine search type based on options
|
|
33
|
-
|
|
135
|
+
const tags = options.tag || [];
|
|
136
|
+
if (tags.length > 0 && searchTerms.length === 0) {
|
|
34
137
|
// Tag-only search
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
138
|
+
const normalizedTags = tags.map(normalizeTag);
|
|
139
|
+
const useOrLogic = options.any || false;
|
|
140
|
+
const result = await searchOps.searchForTag(normalizedTags[0], options.page);
|
|
141
|
+
let matches = result.matches;
|
|
142
|
+
if (normalizedTags.length > 1) {
|
|
143
|
+
matches = matches.filter(m => {
|
|
144
|
+
if (useOrLogic) {
|
|
145
|
+
return normalizedTags.some(tag => contentHasTag(m.content, tag));
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
return normalizedTags.every(tag => contentHasTag(m.content, tag));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
38
151
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
152
|
+
const negTags = options.negtag || [];
|
|
153
|
+
if (negTags.length > 0) {
|
|
154
|
+
const normalizedNegTags = negTags.map(normalizeTag);
|
|
155
|
+
matches = matches.filter(m => !normalizedNegTags.some(tag => contentHasTag(m.content, tag)));
|
|
156
|
+
}
|
|
157
|
+
const limitedMatches = matches.slice(0, limit);
|
|
41
158
|
console.log(formatSearchResults(limitedMatches, outputOptions));
|
|
42
159
|
}
|
|
43
|
-
else if (
|
|
44
|
-
// Text search
|
|
45
|
-
const searchText =
|
|
46
|
-
if (options.debug) {
|
|
47
|
-
printDebug('Text search', { text: searchText, page: options.page, tag: options.tag });
|
|
48
|
-
}
|
|
160
|
+
else if (searchTerms.length > 0) {
|
|
161
|
+
// Text search
|
|
162
|
+
const searchText = searchTerms.join(' ');
|
|
49
163
|
const result = await searchOps.searchByText({
|
|
50
164
|
text: searchText,
|
|
51
165
|
page_title_uid: options.page
|
|
52
166
|
});
|
|
53
|
-
// Apply client-side filters
|
|
54
167
|
let matches = result.matches;
|
|
55
|
-
// Case-insensitive filter if requested
|
|
56
168
|
if (options.caseInsensitive) {
|
|
57
169
|
const lowerSearchText = searchText.toLowerCase();
|
|
58
170
|
matches = matches.filter(m => m.content.toLowerCase().includes(lowerSearchText));
|
|
59
171
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
matches = matches.filter(m =>
|
|
64
|
-
|
|
65
|
-
|
|
172
|
+
if (tags.length > 0) {
|
|
173
|
+
const normalizedTags = tags.map(normalizeTag);
|
|
174
|
+
const useOrLogic = options.any || false;
|
|
175
|
+
matches = matches.filter(m => {
|
|
176
|
+
if (useOrLogic) {
|
|
177
|
+
return normalizedTags.some(tag => contentHasTag(m.content, tag));
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
return normalizedTags.every(tag => contentHasTag(m.content, tag));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
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)));
|
|
66
188
|
}
|
|
67
|
-
// Apply limit
|
|
68
189
|
console.log(formatSearchResults(matches.slice(0, limit), outputOptions));
|
|
69
190
|
}
|
|
70
191
|
else {
|
|
@@ -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,194 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { BatchOperations } from '../../tools/operations/batch.js';
|
|
3
|
+
import { BlockRetrievalOperations } from '../../tools/operations/block-retrieval.js';
|
|
4
|
+
import { parseMarkdownHeadingLevel } from '../../markdown-utils.js';
|
|
5
|
+
import { printDebug, exitWithError } from '../utils/output.js';
|
|
6
|
+
import { resolveGraph } from '../utils/graph.js';
|
|
7
|
+
import { readStdin } from '../utils/input.js';
|
|
8
|
+
// Patterns for TODO/DONE markers (both {{TODO}} and {{[[TODO]]}} formats)
|
|
9
|
+
const TODO_PATTERN = /\{\{(\[\[)?TODO(\]\])?\}\}\s*/g;
|
|
10
|
+
const DONE_PATTERN = /\{\{(\[\[)?DONE(\]\])?\}\}\s*/g;
|
|
11
|
+
const ANY_STATUS_PATTERN = /\{\{(\[\[)?(TODO|DONE)(\]\])?\}\}\s*/g;
|
|
12
|
+
/**
|
|
13
|
+
* Apply TODO/DONE status to content
|
|
14
|
+
* - If target status marker exists, no change needed
|
|
15
|
+
* - If opposite status exists, replace it
|
|
16
|
+
* - If no status exists, prepend
|
|
17
|
+
*/
|
|
18
|
+
function applyStatus(content, status) {
|
|
19
|
+
const marker = `{{[[${status}]]}} `;
|
|
20
|
+
const hasStatus = status === 'TODO'
|
|
21
|
+
? TODO_PATTERN.test(content)
|
|
22
|
+
: DONE_PATTERN.test(content);
|
|
23
|
+
// Reset regex lastIndex
|
|
24
|
+
TODO_PATTERN.lastIndex = 0;
|
|
25
|
+
DONE_PATTERN.lastIndex = 0;
|
|
26
|
+
if (hasStatus) {
|
|
27
|
+
return content; // Already has the target status
|
|
28
|
+
}
|
|
29
|
+
// Check for opposite status and replace
|
|
30
|
+
const oppositePattern = status === 'TODO' ? DONE_PATTERN : TODO_PATTERN;
|
|
31
|
+
if (oppositePattern.test(content)) {
|
|
32
|
+
oppositePattern.lastIndex = 0;
|
|
33
|
+
return content.replace(oppositePattern, marker);
|
|
34
|
+
}
|
|
35
|
+
// No status exists, prepend
|
|
36
|
+
return marker + content;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Remove any TODO/DONE status from content
|
|
40
|
+
*/
|
|
41
|
+
function clearStatus(content) {
|
|
42
|
+
return content.replace(ANY_STATUS_PATTERN, '').trim();
|
|
43
|
+
}
|
|
44
|
+
export function createUpdateCommand() {
|
|
45
|
+
return new Command('update')
|
|
46
|
+
.description('Update block content, heading, open/closed state, or TODO/DONE status')
|
|
47
|
+
.argument('<uid>', 'Block UID to update (accepts ((uid)) wrapper)')
|
|
48
|
+
.argument('[content]', 'New content. Use # prefix for heading: "# Title" sets H1. Reads from stdin if "-" or omitted (when piped).')
|
|
49
|
+
.option('-H, --heading <level>', 'Set heading level (1-3), or 0 to remove')
|
|
50
|
+
.option('-o, --open', 'Expand block (show children)')
|
|
51
|
+
.option('-c, --closed', 'Collapse block (hide children)')
|
|
52
|
+
.option('-T, --todo', 'Set as TODO (replaces DONE if present, prepends if none)')
|
|
53
|
+
.option('-D, --done', 'Set as DONE (replaces TODO if present, prepends if none)')
|
|
54
|
+
.option('--clear-status', 'Remove TODO/DONE marker')
|
|
55
|
+
.option('-g, --graph <name>', 'Target graph key (multi-graph mode)')
|
|
56
|
+
.option('--write-key <key>', 'Write confirmation key (non-default graphs)')
|
|
57
|
+
.option('--debug', 'Show debug information')
|
|
58
|
+
.addHelpText('after', `
|
|
59
|
+
Examples:
|
|
60
|
+
# Basic update
|
|
61
|
+
roam update abc123def "New content" # Update block text
|
|
62
|
+
roam update "((abc123def))" "New content" # UID with wrapper
|
|
63
|
+
|
|
64
|
+
# Heading updates
|
|
65
|
+
roam update abc123def "# Main Title" # Auto-detect H1, strip #
|
|
66
|
+
roam update abc123def "Title" -H 2 # Explicit H2
|
|
67
|
+
roam update abc123def "Plain text" -H 0 # Remove heading
|
|
68
|
+
|
|
69
|
+
# Block state
|
|
70
|
+
roam update abc123def "Content" -o # Expand block
|
|
71
|
+
roam update abc123def "Content" -c # Collapse block
|
|
72
|
+
|
|
73
|
+
# TODO/DONE status
|
|
74
|
+
roam update abc123def "Task" -T # Set as TODO
|
|
75
|
+
roam update abc123def "Task" -D # Mark as DONE
|
|
76
|
+
roam update abc123def "Task" --clear-status # Remove status marker
|
|
77
|
+
|
|
78
|
+
# Stdin / Partial Updates
|
|
79
|
+
echo "New text" | roam update abc123def # Pipe content
|
|
80
|
+
roam update abc123def -T # Add TODO (fetches existing text)
|
|
81
|
+
roam update abc123def -o # Expand block (keeps text)
|
|
82
|
+
`)
|
|
83
|
+
.action(async (uid, content, options) => {
|
|
84
|
+
try {
|
|
85
|
+
// Strip (( )) wrapper if present
|
|
86
|
+
const blockUid = uid.replace(/^\(\(|\)\)$/g, '');
|
|
87
|
+
const graph = resolveGraph(options, true); // This is a write operation
|
|
88
|
+
let finalContent;
|
|
89
|
+
// 1. Determine new content from args or stdin
|
|
90
|
+
if (content && content !== '-') {
|
|
91
|
+
finalContent = content;
|
|
92
|
+
}
|
|
93
|
+
else if (content === '-' || (!content && !process.stdin.isTTY)) {
|
|
94
|
+
finalContent = await readStdin();
|
|
95
|
+
finalContent = finalContent.trim();
|
|
96
|
+
}
|
|
97
|
+
// 2. Identify if we need to fetch existing content
|
|
98
|
+
const isStatusUpdate = options.todo || options.done || options.clearStatus;
|
|
99
|
+
const isHeadingUpdate = options.heading !== undefined;
|
|
100
|
+
const isStateUpdate = options.open || options.closed;
|
|
101
|
+
if (finalContent === undefined) {
|
|
102
|
+
if (isStatusUpdate) {
|
|
103
|
+
// Must fetch to apply status safely
|
|
104
|
+
const blockRetrieval = new BlockRetrievalOperations(graph);
|
|
105
|
+
const block = await blockRetrieval.fetchBlockWithChildren(blockUid, 0);
|
|
106
|
+
if (!block) {
|
|
107
|
+
exitWithError(`Block ${blockUid} not found`);
|
|
108
|
+
}
|
|
109
|
+
finalContent = block.string;
|
|
110
|
+
}
|
|
111
|
+
else if (!isHeadingUpdate && !isStateUpdate) {
|
|
112
|
+
exitWithError('No content or update options provided.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 3. Process content if we have it
|
|
116
|
+
let headingLevel;
|
|
117
|
+
if (finalContent !== undefined) {
|
|
118
|
+
// Handle explicit heading option
|
|
119
|
+
if (options.heading !== undefined) {
|
|
120
|
+
const level = parseInt(options.heading, 10);
|
|
121
|
+
if (level >= 0 && level <= 3) {
|
|
122
|
+
headingLevel = level === 0 ? undefined : level;
|
|
123
|
+
const { content: stripped } = parseMarkdownHeadingLevel(finalContent);
|
|
124
|
+
finalContent = stripped;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// Auto-detect heading from content
|
|
129
|
+
const { heading_level, content: stripped } = parseMarkdownHeadingLevel(finalContent);
|
|
130
|
+
if (heading_level > 0) {
|
|
131
|
+
headingLevel = heading_level;
|
|
132
|
+
finalContent = stripped;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Handle TODO/DONE status
|
|
136
|
+
if (options.clearStatus) {
|
|
137
|
+
finalContent = clearStatus(finalContent);
|
|
138
|
+
}
|
|
139
|
+
else if (options.todo) {
|
|
140
|
+
finalContent = applyStatus(finalContent, 'TODO');
|
|
141
|
+
}
|
|
142
|
+
else if (options.done) {
|
|
143
|
+
finalContent = applyStatus(finalContent, 'DONE');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// No content update, just metadata
|
|
148
|
+
if (options.heading !== undefined) {
|
|
149
|
+
const level = parseInt(options.heading, 10);
|
|
150
|
+
if (level >= 0 && level <= 3) {
|
|
151
|
+
headingLevel = level === 0 ? undefined : level;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Handle open/closed state
|
|
156
|
+
let openState;
|
|
157
|
+
if (options.open) {
|
|
158
|
+
openState = true;
|
|
159
|
+
}
|
|
160
|
+
else if (options.closed) {
|
|
161
|
+
openState = false;
|
|
162
|
+
}
|
|
163
|
+
if (options.debug) {
|
|
164
|
+
printDebug('Block UID', blockUid);
|
|
165
|
+
printDebug('Graph', options.graph || 'default');
|
|
166
|
+
printDebug('Content', finalContent !== undefined ? finalContent : '(no change)');
|
|
167
|
+
printDebug('Heading level', headingLevel ?? 'none');
|
|
168
|
+
printDebug('Open state', openState ?? 'unchanged');
|
|
169
|
+
printDebug('Status', options.todo ? 'TODO' : options.done ? 'DONE' : options.clearStatus ? 'cleared' : 'unchanged');
|
|
170
|
+
}
|
|
171
|
+
const batchOps = new BatchOperations(graph);
|
|
172
|
+
const result = await batchOps.processBatch([{
|
|
173
|
+
action: 'update-block',
|
|
174
|
+
uid: blockUid,
|
|
175
|
+
string: finalContent,
|
|
176
|
+
...(headingLevel !== undefined && { heading: headingLevel }),
|
|
177
|
+
...(openState !== undefined && { open: openState })
|
|
178
|
+
}]);
|
|
179
|
+
if (result.success) {
|
|
180
|
+
console.log(`Updated block ${blockUid}`);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const errorMsg = typeof result.error === 'string'
|
|
184
|
+
? result.error
|
|
185
|
+
: result.error?.message || 'Unknown error';
|
|
186
|
+
exitWithError(`Failed to update block: ${errorMsg}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
+
exitWithError(message);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
package/build/cli/roam.js
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
3
6
|
import { createGetCommand } from './commands/get.js';
|
|
4
7
|
import { createSearchCommand } from './commands/search.js';
|
|
5
8
|
import { createSaveCommand } from './commands/save.js';
|
|
6
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;
|
|
7
20
|
const program = new Command();
|
|
8
21
|
program
|
|
9
22
|
.name('roam')
|
|
10
23
|
.description('CLI for Roam Research')
|
|
11
|
-
.version(
|
|
24
|
+
.version(cliVersion);
|
|
12
25
|
// Register subcommands
|
|
13
26
|
program.addCommand(createGetCommand());
|
|
14
27
|
program.addCommand(createSearchCommand());
|
|
15
28
|
program.addCommand(createSaveCommand());
|
|
16
29
|
program.addCommand(createRefsCommand());
|
|
30
|
+
program.addCommand(createUpdateCommand());
|
|
31
|
+
program.addCommand(createBatchCommand());
|
|
32
|
+
program.addCommand(createRenameCommand());
|
|
33
|
+
program.addCommand(createStatusCommand());
|
|
17
34
|
// Parse arguments
|
|
18
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
|
+
}
|
|
@@ -73,6 +73,40 @@ export function formatSearchResults(results, options) {
|
|
|
73
73
|
});
|
|
74
74
|
return output.trim();
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Format TODO/DONE search results for output
|
|
78
|
+
*/
|
|
79
|
+
export function formatTodoOutput(results, status, options) {
|
|
80
|
+
if (options.json) {
|
|
81
|
+
return JSON.stringify(results, null, 2);
|
|
82
|
+
}
|
|
83
|
+
if (results.length === 0) {
|
|
84
|
+
return `No ${status} items found.`;
|
|
85
|
+
}
|
|
86
|
+
// Group by page
|
|
87
|
+
const byPage = new Map();
|
|
88
|
+
for (const item of results) {
|
|
89
|
+
const page = item.page_title || 'Unknown Page';
|
|
90
|
+
if (!byPage.has(page)) {
|
|
91
|
+
byPage.set(page, []);
|
|
92
|
+
}
|
|
93
|
+
byPage.get(page).push(item);
|
|
94
|
+
}
|
|
95
|
+
let output = `Found ${results.length} ${status} item(s):\n`;
|
|
96
|
+
for (const [page, items] of byPage) {
|
|
97
|
+
output += `\n## ${page}\n`;
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
// Strip {{[[TODO]]}}, {{TODO}}, {{[[DONE]]}}, or {{DONE}} markers for cleaner display
|
|
100
|
+
const cleanContent = item.content
|
|
101
|
+
.replace(/\{\{\[\[TODO\]\]\}\}\s*/g, '')
|
|
102
|
+
.replace(/\{\{TODO\}\}\s*/g, '')
|
|
103
|
+
.replace(/\{\{\[\[DONE\]\]\}\}\s*/g, '')
|
|
104
|
+
.replace(/\{\{DONE\}\}\s*/g, '');
|
|
105
|
+
output += `- [${status === 'DONE' ? 'x' : ' '}] ${cleanContent} (${item.block_uid})\n`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return output.trim();
|
|
109
|
+
}
|
|
76
110
|
/**
|
|
77
111
|
* Print debug information
|
|
78
112
|
*/
|