/ src / tools / submodule.ts
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  };