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.
@@ -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 for content in Roam')
9
- .argument('[terms...]', 'Search terms (multiple terms use AND logic)')
10
- .option('--tag <tag>', 'Filter by tag (e.g., "#TODO" or "[[Project]]")')
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 = initializeGraph({
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', 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
- if (options.tag && terms.length === 0) {
135
+ const tags = options.tag || [];
136
+ if (tags.length > 0 && searchTerms.length === 0) {
34
137
  // Tag-only search
35
- const tagName = options.tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
36
- if (options.debug) {
37
- printDebug('Tag search', { tag: tagName, page: options.page });
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 result = await searchOps.searchForTag(tagName, options.page);
40
- const limitedMatches = result.matches.slice(0, limit);
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 (terms.length > 0) {
44
- // Text search (with optional tag filter)
45
- const searchText = terms.join(' ');
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
- // Tag filter if provided
61
- if (options.tag) {
62
- const tagPattern = options.tag.replace(/^#?\[?\[?/, '').replace(/\]?\]?$/, '');
63
- matches = matches.filter(m => m.content.includes(`[[${tagPattern}]]`) ||
64
- m.content.includes(`#${tagPattern}`) ||
65
- m.content.includes(`#[[${tagPattern}]]`));
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('1.6.0');
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
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Read all input from stdin
3
+ */
4
+ export async function readStdin() {
5
+ const chunks = [];
6
+ for await (const chunk of process.stdin) {
7
+ chunks.push(chunk);
8
+ }
9
+ return Buffer.concat(chunks).toString('utf-8');
10
+ }
@@ -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
  */