submodule.ts
1 /** 2 * Submodule MCP Tools - DreamNode relationship operations 3 * Uses InterBrain's services via standalone-adapter 4 */ 5 6 import { SubmoduleService, DreamNodeService } from '../services/standalone-adapter.js'; 7 import { exec } from 'child_process'; 8 import { promisify } from 'util'; 9 import * as path from 'path'; 10 import * as fs from 'fs/promises'; 11 12 const execAsync = promisify(exec); 13 14 /** 15 * Tool: add_submodule 16 * Import another DreamNode as a git submodule 17 */ 18 export async function addSubmodule(args: { 19 parent_identifier: string; 20 child_identifier: string; 21 submodule_name?: string; 22 }): Promise<{ 23 success: boolean; 24 parent?: { title: string; path: string }; 25 child?: { title: string; path: string }; 26 submodule_name?: string; 27 error?: string; 28 }> { 29 try { 30 // Find parent DreamNode 31 const parent = await DreamNodeService.getDreamNode(args.parent_identifier); 32 if (!parent) { 33 return { 34 success: false, 35 error: `Parent DreamNode not found: ${args.parent_identifier}` 36 }; 37 } 38 39 // Find child DreamNode 40 const child = await DreamNodeService.getDreamNode(args.child_identifier); 41 if (!child) { 42 return { 43 success: false, 44 error: `Child DreamNode not found: ${args.child_identifier}` 45 }; 46 } 47 48 // Prevent self-reference 49 if (parent.uuid === child.uuid) { 50 return { 51 success: false, 52 error: 'Cannot add a DreamNode as a submodule of itself' 53 }; 54 } 55 56 // Add submodule 57 const result = await SubmoduleService.addSubmodule( 58 parent.path, 59 child.path, 60 args.submodule_name 61 ); 62 63 if (!result.success) { 64 return { 65 success: false, 66 error: result.error || 'Failed to add submodule' 67 }; 68 } 69 70 return { 71 success: true, 72 parent: { title: parent.title, path: parent.path }, 73 child: { title: child.title, path: child.path }, 74 submodule_name: args.submodule_name || child.title 75 }; 76 } catch (error) { 77 return { 78 success: false, 79 error: error instanceof Error ? error.message : 'Unknown error' 80 }; 81 } 82 } 83 84 /** 85 * Tool: remove_submodule 86 * Remove a submodule relationship from a DreamNode 87 */ 88 export async function removeSubmodule(args: { 89 parent_identifier: string; 90 submodule_name: string; 91 }): Promise<{ 92 success: boolean; 93 parent?: { title: string; path: string }; 94 removed_submodule?: string; 95 error?: string; 96 }> { 97 try { 98 // Find parent DreamNode 99 const parent = await DreamNodeService.getDreamNode(args.parent_identifier); 100 if (!parent) { 101 return { 102 success: false, 103 error: `Parent DreamNode not found: ${args.parent_identifier}` 104 }; 105 } 106 107 // Verify submodule exists 108 const submodules = await SubmoduleService.listSubmodules(parent.path); 109 if (!submodules.includes(args.submodule_name)) { 110 return { 111 success: false, 112 error: `Submodule not found: ${args.submodule_name}` 113 }; 114 } 115 116 // Remove submodule 117 const result = await SubmoduleService.removeSubmodule(parent.path, args.submodule_name); 118 119 if (!result.success) { 120 return { 121 success: false, 122 error: result.error || 'Failed to remove submodule' 123 }; 124 } 125 126 return { 127 success: true, 128 parent: { title: parent.title, path: parent.path }, 129 removed_submodule: args.submodule_name 130 }; 131 } catch (error) { 132 return { 133 success: false, 134 error: error instanceof Error ? error.message : 'Unknown error' 135 }; 136 } 137 } 138 139 /** 140 * Tool: list_submodules 141 * List all submodules of a DreamNode 142 */ 143 export async function listSubmodules(args: { 144 identifier: string; 145 }): Promise<{ 146 success: boolean; 147 parent?: { title: string; path: string }; 148 submodules?: string[]; 149 error?: string; 150 }> { 151 try { 152 // Find DreamNode 153 const node = await DreamNodeService.getDreamNode(args.identifier); 154 if (!node) { 155 return { 156 success: false, 157 error: `DreamNode not found: ${args.identifier}` 158 }; 159 } 160 161 // List submodules 162 const submodules = await SubmoduleService.listSubmodules(node.path); 163 164 return { 165 success: true, 166 parent: { title: node.title, path: node.path }, 167 submodules 168 }; 169 } catch (error) { 170 return { 171 success: false, 172 error: error instanceof Error ? error.message : 'Unknown error' 173 }; 174 } 175 } 176 177 /** 178 * Tool: sync_context 179 * Regenerate .claude/submodule-context.md with @imports for all submodule READMEs 180 * This enables Claude Code to load context from the cascading holarchy of submodules 181 * 182 * Process: 183 * 1. Recursively initialize all git submodules 184 * 2. Traverse the full submodule holarchy (nested submodules) 185 * 3. Generate @import statements for all READMEs found 186 */ 187 export async function syncContext(args: { 188 identifier: string; 189 }): Promise<{ 190 success: boolean; 191 dreamnode?: { title: string; path: string }; 192 submodules_found?: string[]; 193 context_file?: string; 194 message?: string; 195 error?: string; 196 }> { 197 try { 198 // Find DreamNode 199 const node = await DreamNodeService.getDreamNode(args.identifier); 200 if (!node) { 201 return { 202 success: false, 203 error: `DreamNode not found: ${args.identifier}` 204 }; 205 } 206 207 const claudeDir = path.join(node.path, '.claude'); 208 const outputFile = path.join(claudeDir, 'submodule-context.md'); 209 210 // Ensure .claude directory exists 211 await fs.mkdir(claudeDir, { recursive: true }); 212 213 // First, recursively initialize all submodules 214 try { 215 await execAsync('git submodule update --init --recursive', { cwd: node.path }); 216 } catch (gitError) { 217 // Non-fatal: submodules might already be initialized or there might be none 218 console.error('Warning: git submodule update failed (may be expected):', gitError); 219 } 220 221 // Get all submodules recursively using git submodule foreach 222 const foundSubmodules: string[] = []; 223 try { 224 const { stdout } = await execAsync( 225 "git submodule foreach --recursive --quiet 'echo $displaypath'", 226 { cwd: node.path } 227 ); 228 const submodulePaths = stdout.trim().split('\n').filter(p => p.length > 0); 229 230 for (const submodulePath of submodulePaths) { 231 const readmePath = path.join(node.path, submodulePath, 'README.md'); 232 try { 233 await fs.access(readmePath); 234 foundSubmodules.push(submodulePath); 235 } catch { 236 // README doesn't exist, skip 237 } 238 } 239 } catch { 240 // No submodules or git error - continue with empty list 241 } 242 243 // Generate context file content 244 let content = `# Submodule Context (Auto-Generated) 245 246 This file is auto-generated by the sync_context MCP tool. 247 Do not edit manually - it will be overwritten. 248 249 The following submodule READMEs are imported into context (recursive traversal): 250 251 `; 252 253 for (const submodulePath of foundSubmodules) { 254 content += `## ${submodulePath}\n\n`; 255 content += `@${submodulePath}/README.md\n\n`; 256 } 257 258 if (foundSubmodules.length === 0) { 259 content += '*No submodules with READMEs found.*\n'; 260 } 261 262 content += `\n---\n*Last synced: ${new Date().toISOString()}*\n`; 263 264 // Write the file 265 await fs.writeFile(outputFile, content, 'utf-8'); 266 267 return { 268 success: true, 269 dreamnode: { title: node.title, path: node.path }, 270 submodules_found: foundSubmodules, 271 context_file: outputFile, 272 message: 'Context synced. Run /resume or start a new chat to load the updated context.' 273 }; 274 } catch (error) { 275 return { 276 success: false, 277 error: error instanceof Error ? error.message : 'Unknown error' 278 }; 279 } 280 } 281 282 /** 283 * Export tool definitions for MCP registration 284 */ 285 export const submoduleTools = { 286 add_submodule: { 287 name: 'add_submodule', 288 description: 'Import another DreamNode as a git submodule, establishing a hierarchical relationship', 289 inputSchema: { 290 type: 'object' as const, 291 properties: { 292 parent_identifier: { 293 type: 'string', 294 description: 'UUID or title of the parent DreamNode' 295 }, 296 child_identifier: { 297 type: 'string', 298 description: 'UUID or title of the child DreamNode to import' 299 }, 300 submodule_name: { 301 type: 'string', 302 description: 'Optional custom name for the submodule (defaults to child title)' 303 } 304 }, 305 required: ['parent_identifier', 'child_identifier'] 306 }, 307 handler: addSubmodule 308 }, 309 310 remove_submodule: { 311 name: 'remove_submodule', 312 description: 'Remove a submodule relationship from a DreamNode', 313 inputSchema: { 314 type: 'object' as const, 315 properties: { 316 parent_identifier: { 317 type: 'string', 318 description: 'UUID or title of the parent DreamNode' 319 }, 320 submodule_name: { 321 type: 'string', 322 description: 'Name of the submodule to remove' 323 } 324 }, 325 required: ['parent_identifier', 'submodule_name'] 326 }, 327 handler: removeSubmodule 328 }, 329 330 list_submodules: { 331 name: 'list_submodules', 332 description: 'List all submodules of a DreamNode', 333 inputSchema: { 334 type: 'object' as const, 335 properties: { 336 identifier: { 337 type: 'string', 338 description: 'UUID or title of the DreamNode' 339 } 340 }, 341 required: ['identifier'] 342 }, 343 handler: listSubmodules 344 }, 345 346 sync_context: { 347 name: 'sync_context', 348 description: 'Regenerate .claude/submodule-context.md with @imports for all submodule READMEs. Call this after importing submodules to update Claude Code context. The user will need to start a new chat or reload context to see changes.', 349 inputSchema: { 350 type: 'object' as const, 351 properties: { 352 identifier: { 353 type: 'string', 354 description: 'UUID or title of the DreamNode to sync context for' 355 } 356 }, 357 required: ['identifier'] 358 }, 359 handler: syncContext 360 } 361 };