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.
Files changed (34) hide show
  1. package/README.md +360 -31
  2. package/build/Roam_Markdown_Cheatsheet.md +30 -12
  3. package/build/cli/batch/resolver.js +138 -0
  4. package/build/cli/batch/translator.js +363 -0
  5. package/build/cli/batch/types.js +4 -0
  6. package/build/cli/commands/batch.js +352 -0
  7. package/build/cli/commands/get.js +161 -0
  8. package/build/cli/commands/refs.js +135 -0
  9. package/build/cli/commands/rename.js +58 -0
  10. package/build/cli/commands/save.js +498 -0
  11. package/build/cli/commands/search.js +240 -0
  12. package/build/cli/commands/status.js +91 -0
  13. package/build/cli/commands/update.js +151 -0
  14. package/build/cli/roam.js +35 -0
  15. package/build/cli/utils/graph.js +56 -0
  16. package/build/cli/utils/output.js +122 -0
  17. package/build/config/environment.js +70 -34
  18. package/build/config/graph-registry.js +221 -0
  19. package/build/config/graph-registry.test.js +30 -0
  20. package/build/search/block-ref-search.js +34 -7
  21. package/build/search/status-search.js +5 -4
  22. package/build/server/roam-server.js +98 -53
  23. package/build/shared/validation.js +10 -5
  24. package/build/tools/helpers/refs.js +50 -31
  25. package/build/tools/operations/blocks.js +38 -1
  26. package/build/tools/operations/memory.js +51 -5
  27. package/build/tools/operations/pages.js +186 -111
  28. package/build/tools/operations/search/index.js +5 -1
  29. package/build/tools/operations/todos.js +1 -1
  30. package/build/tools/schemas.js +121 -41
  31. package/build/tools/tool-handlers.js +9 -2
  32. package/build/utils/helpers.js +22 -0
  33. package/package.json +11 -7
  34. 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
+ }