/ src / features / uri-handler / uri-handler-service.ts
uri-handler-service.ts
  1  import { App, Notice, Plugin } from 'obsidian';
  2  import { RadicleService } from '../social-resonance-filter/services/radicle-service';
  3  import { GitDreamNodeService } from '../dreamnode/services/git-dreamnode-service';
  4  import { UDDService } from '../dreamnode/services/udd-service';
  5  import { DreamSongRelationshipService } from '../dreamweaving/services/dreamsong-relationship-service';
  6  import { useInterBrainStore } from '../../core/store/interbrain-store';
  7  import { DreamNode } from '../dreamnode';
  8  
  9  /**
 10   * URI Handler Service
 11   *
 12   * Registers and handles custom Obsidian URI protocols for deep linking.
 13   * Enables one-click DreamNode cloning from email links.
 14   */
 15  export class URIHandlerService {
 16  	private app: App;
 17  	private plugin: Plugin;
 18  	private radicleService: RadicleService;
 19  	private dreamNodeService: GitDreamNodeService;
 20  
 21  	constructor(app: App, plugin: Plugin, radicleService: RadicleService, dreamNodeService: GitDreamNodeService) {
 22  		this.app = app;
 23  		this.plugin = plugin;
 24  		this.radicleService = radicleService;
 25  		this.dreamNodeService = dreamNodeService;
 26  	}
 27  
 28  	/**
 29  	 * Register all URI handlers
 30  	 */
 31  	registerHandlers(): void {
 32  		try {
 33  			// Clone handler: obsidian://interbrain-clone?ids=<id1,id2,...>&senderDid=<did>&senderName=<name>
 34  			this.plugin.registerObsidianProtocolHandler(
 35  				'interbrain-clone',
 36  				this.handleClone.bind(this)
 37  			);
 38  
 39  			// Update contact handler: obsidian://interbrain-update-contact?did=<did>&uuid=<uuid>&name=<name>&email=<email>
 40  			this.plugin.registerObsidianProtocolHandler(
 41  				'interbrain-update-contact',
 42  				this.handleUpdateContact.bind(this)
 43  			);
 44  		// Universal command handler: obsidian://interbrain?command=<command>&uuid=<uuid>
 45  			this.plugin.registerObsidianProtocolHandler(
 46  				'interbrain',
 47  				this.handleCommand.bind(this)
 48  			);
 49  		} catch (error) {
 50  			console.error('[URIHandler] Failed to register handlers:', error);
 51  		}
 52  	}
 53  
 54  	/**
 55  	 * Universal command handler for external tools (e.g. Alfred)
 56  	 * Format: obsidian://interbrain?command=<command>&uuid=<uuid>
 57  	 *
 58  	 * If uuid is provided, selects the DreamNode before executing the command.
 59  	 */
 60  	private async handleCommand(params: Record<string, string>): Promise<void> {
 61  		try {
 62  			const command = params.command;
 63  			if (!command) {
 64  				new Notice('Invalid InterBrain URI: missing command parameter');
 65  				return;
 66  			}
 67  
 68  			const uuid = params.uuid;
 69  
 70  			// If a UUID is provided, select the node first
 71  			if (uuid) {
 72  				const store = useInterBrainStore.getState();
 73  				const nodeData = store.dreamNodes.get(uuid);
 74  				if (nodeData) {
 75  					store.setSelectedNode(nodeData.node);
 76  					store.requestNavigation({ type: 'liminal-web-focus', nodeId: nodeData.node.id });
 77  				} else {
 78  					console.warn(`[URIHandler] DreamNode not found for UUID: ${uuid}`);
 79  					new Notice(`DreamNode not found: ${uuid.slice(0, 8)}...`);
 80  					return;
 81  				}
 82  			}
 83  
 84  			// Execute the command via Obsidian's command palette
 85  			const commandId = `interbrain:${command}`;
 86  			const executed = (this.app as any).commands.executeCommandById(commandId);
 87  			if (!executed) {
 88  				new Notice(`Unknown InterBrain command: ${command}`);
 89  			}
 90  		} catch (error) {
 91  			console.error('[URIHandler] Failed to handle command:', error);
 92  			new Notice(`Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`);
 93  		}
 94  	}
 95  
 96  	/**
 97  	 * Unified clone handler - handles both single and batch clones
 98  	 * Format: obsidian://interbrain-clone?ids=<id1,id2,...>&senderDid=<did>&senderName=<name>
 99  	 *
100  	 * Selection logic:
101  	 * - Single ID (ids=rad:z1234): Selects the cloned Dream node
102  	 * - Multiple IDs (ids=rad:z1234,rad:z5678): Selects the Dreamer node
103  	 */
104  	private async handleClone(params: Record<string, string>): Promise<void> {
105  		try {
106  			const ids = params.ids || params.id || params.uuid || params.uuids; // Support legacy formats
107  			const senderDid = params.senderDid ? decodeURIComponent(params.senderDid) : undefined;
108  			const senderName = params.senderName ? decodeURIComponent(params.senderName) : undefined;
109  
110  			if (!ids) {
111  				new Notice('Invalid clone link: missing node identifiers');
112  				return;
113  			}
114  
115  			const identifiers = ids.split(',').map(u => u.trim()).filter(Boolean);
116  
117  			if (identifiers.length === 0) {
118  				new Notice('Invalid clone link: no valid identifiers');
119  				return;
120  			}
121  
122  			const isSingleClone = identifiers.length === 1;
123  
124  			// Classify each identifier
125  			const classified = identifiers.map(id => ({
126  				raw: id,
127  				type: this.classifyIdentifier(id)
128  			}));
129  
130  			// Show progress notification
131  			const notice = new Notice(`Cloning ${identifiers.length} DreamNode${identifiers.length > 1 ? 's' : ''} in parallel...`, 0);
132  
133  			// PARALLELIZED: Clone all nodes simultaneously
134  			const clonePromises = classified.map(async ({ raw, type }) => {
135  				try {
136  					// Determine clone method based on type
137  					let result: 'success' | 'skipped' | 'error';
138  					if (type === 'github') {
139  						result = await this.cloneFromGitHub(raw, true); // silent=true
140  					} else if (type === 'radicle') {
141  						// Pass senderDid as peerNid for direct P2P clone
142  						const cloneResult = await this.cloneFromRadicle(raw, true, senderDid);
143  						result = cloneResult.status;
144  					} else {
145  						console.warn(`⚠️ [URIHandler] UUID-based clone not implemented: ${raw}`);
146  						return { result: 'error', identifier: raw, type };
147  					}
148  
149  					return { result, identifier: raw, type };
150  				} catch (error) {
151  					console.error(`❌ [URIHandler] Failed to clone ${type} identifier "${raw}":`, error);
152  					return { result: 'error' as const, identifier: raw, type };
153  				}
154  			});
155  
156  			// Wait for all clones to complete
157  			const results = await Promise.all(clonePromises);
158  
159  			// Count results
160  			let successCount = 0;
161  			let skipCount = 0;
162  			let errorCount = 0;
163  
164  			results.forEach(({ result }) => {
165  				if (result === 'success') successCount++;
166  				else if (result === 'skipped') skipCount++;
167  				else errorCount++;
168  			});
169  
170  			notice.hide();
171  
172  			// Show comprehensive summary with appropriate icon
173  			const parts: string[] = [];
174  			if (successCount > 0) parts.push(`${successCount} cloned`);
175  			if (skipCount > 0) parts.push(`${skipCount} already existed`);
176  			if (errorCount > 0) parts.push(`${errorCount} failed`);
177  
178  			const summary = parts.join(', ');
179  
180  			if (errorCount > 0 && successCount === 0 && skipCount === 0) {
181  				// All failed - show error with explanation
182  				new Notice(
183  					`❌ Clone failed. The DreamNode may not be available on the network yet. ` +
184  					`The sender should ensure they've shared the link after the node is synced.`,
185  					10000
186  				);
187  			} else if (errorCount > 0) {
188  				// Partial failure
189  				new Notice(`⚠️ Clone partial: ${summary}. Some nodes may need the sender to sync first.`, 8000);
190  			} else {
191  				// All success or skipped
192  				new Notice(`✅ Clone complete: ${summary}`);
193  			}
194  
195  			// Determine if anything actually changed (new clones vs all already existed)
196  			const allNodesAlreadyExisted = successCount === 0 && skipCount > 0;
197  
198  			// If we have sender info, handle collaboration handshake
199  			if (senderDid && senderName) {
200  				if (allNodesAlreadyExisted) {
201  					// Fast path: nodes exist, ensure Dreamer + relationships
202  
203  					// Get current store state (nodes already loaded)
204  					const store = useInterBrainStore.getState();
205  
206  					// Convert Map to array for searching
207  					const nodesArray = Array.from(store.dreamNodes.values()).map(nodeData => nodeData.node);
208  
209  					// Check if Dreamer node exists
210  					let dreamerNode = nodesArray.find((n: any) => n.type === 'dreamer' && n.did === senderDid);
211  
212  					// Extract senderEmail from params if available
213  					const senderEmail = params.senderEmail ? decodeURIComponent(params.senderEmail) : undefined;
214  
215  					if (!dreamerNode) {
216  						// Create Dreamer node and link relationships
217  						await this.dreamNodeService.scanVault();
218  						dreamerNode = await this.findOrCreateDreamerNode(senderDid, senderName, senderEmail);
219  						await new Promise(resolve => setTimeout(resolve, 200));
220  
221  						// Link all cloned nodes to the Dreamer
222  						for (const identifier of identifiers) {
223  							try {
224  								const clonedNode = await this.findNodeByIdentifier(identifier);
225  								if (clonedNode) {
226  									await this.linkNodes(clonedNode, dreamerNode);
227  								}
228  							} catch {
229  								// Non-critical link failure
230  							}
231  						}
232  
233  						// Sync Radicle peer relationships
234  						try {
235  							await (this.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following');
236  						} catch {
237  							// Non-critical sync failure
238  						}
239  					} else {
240  						// Dreamer exists - DreamNode.id is already the UUID
241  						(dreamerNode as any).uuid = dreamerNode.id;
242  
243  						// Check if all cloned nodes are linked to this Dreamer
244  						let missingLinks = false;
245  						for (const identifier of identifiers) {
246  							const clonedNode = await this.findNodeByIdentifier(identifier);
247  							if (clonedNode && !dreamerNode.liminalWebConnections?.includes(clonedNode.id)) {
248  								missingLinks = true;
249  								await this.linkNodes(clonedNode, dreamerNode);
250  							}
251  						}
252  
253  						if (missingLinks) {
254  							await this.dreamNodeService.scanVault();
255  
256  							try {
257  								await (this.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following');
258  							} catch {
259  								// Non-critical
260  							}
261  						}
262  					}
263  
264  					// Select the appropriate target node
265  					let targetNode: any;
266  					if (isSingleClone) {
267  						// Single clone: Select the cloned Dream node
268  						targetNode = nodesArray.find(n => {
269  							if (identifiers[0].startsWith('rad:')) {
270  								return n.radicleId === identifiers[0];
271  							}
272  							if (identifiers[0].includes('github.com/')) {
273  								return n.githubRepoUrl?.includes(identifiers[0]);
274  							}
275  							return false;
276  						});
277  					} else {
278  						// Batch clone: Select the Dreamer node
279  						targetNode = dreamerNode;
280  					}
281  
282  					if (targetNode) {
283  						store.setSelectedNode(targetNode);
284  					} else {
285  						await (this.app as any).commands.executeCommandById('interbrain:refresh-plugin');
286  					}
287  
288  				} else {
289  					// Full path: New nodes cloned - run complete workflow
290  					await this.dreamNodeService.scanVault();
291  
292  					const senderEmail = params.senderEmail ? decodeURIComponent(params.senderEmail) : undefined;
293  					const dreamerNode = await this.findOrCreateDreamerNode(senderDid, senderName, senderEmail);
294  					await new Promise(resolve => setTimeout(resolve, 200));
295  
296  					// Link all successfully cloned nodes to the Dreamer node
297  					for (const { result, identifier } of results) {
298  						if (result === 'success' || result === 'skipped') {
299  							try {
300  								const clonedNode = await this.findNodeByIdentifier(identifier);
301  								if (clonedNode) {
302  									await this.linkNodes(clonedNode, dreamerNode);
303  								}
304  							} catch {
305  								// Non-critical link failure
306  							}
307  						}
308  					}
309  
310  					// Sync Radicle peer relationships
311  					try {
312  						await (this.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following');
313  					} catch {
314  						// Non-critical sync failure
315  					}
316  
317  					// Refresh UI with smart selection
318  					try {
319  						let targetUUID: string | undefined;
320  						if (isSingleClone) {
321  							const clonedNode = await this.findNodeByIdentifier(identifiers[0]);
322  							targetUUID = clonedNode?.id;
323  						} else {
324  							targetUUID = dreamerNode.id;
325  						}
326  
327  						if (targetUUID) {
328  							(globalThis as any).__interbrainReloadTargetUUID = targetUUID;
329  						}
330  						await (this.app as any).commands.executeCommandById('interbrain:refresh-plugin');
331  					} catch {
332  						// Non-critical refresh failure
333  					}
334  				}
335  			}
336  
337  		} catch (error) {
338  			console.error('Failed to handle clone link:', error);
339  			new Notice(`Failed to handle clone: ${error instanceof Error ? error.message : 'Unknown error'}`);
340  		}
341  	}
342  
343  	/**
344  	 * Handle contact update (DID backpropagation from Bob → Alice)
345  	 * Format: obsidian://interbrain-update-contact?did=<did>&uuid=<dreamer-uuid>&name=<name>&email=<email>
346  	 *
347  	 * This enables the collaboration handshake completion:
348  	 * 1. Alice shares with Bob → Bob installs → Bob gets Alice's DID
349  	 * 2. Bob shares DID back to Alice via this URI
350  	 * 3. Alice's Dreamer node for Bob gets updated with his DID
351  	 * 4. Sync command auto-triggers → mutual delegation established
352  	 */
353  	private async handleUpdateContact(params: Record<string, string>): Promise<void> {
354  		try {
355  			const did = params.did ? decodeURIComponent(params.did) : undefined;
356  			const uuid = params.uuid ? decodeURIComponent(params.uuid) : undefined;
357  			const name = params.name ? decodeURIComponent(params.name) : undefined;
358  			const email = params.email ? decodeURIComponent(params.email) : undefined;
359  
360  			if (!did) {
361  				new Notice('Invalid update link: missing DID');
362  				return;
363  			}
364  
365  			if (!uuid) {
366  				new Notice('Invalid update link: missing Dreamer UUID');
367  				return;
368  			}
369  
370  			// Find the Dreamer node by UUID
371  			const allNodes = await this.dreamNodeService.list();
372  			const dreamerNode = allNodes.find((node: any) => node.id === uuid && node.type === 'dreamer');
373  
374  			if (!dreamerNode) {
375  				new Notice(`Dreamer node not found (UUID: ${uuid.slice(0, 8)}...)`);
376  				return;
377  			}
378  
379  			// Update the Dreamer node with new contact info
380  			const updates: Partial<DreamNode> = { did };
381  			if (name) updates.name = name;
382  			if (email) updates.email = email;
383  
384  			await this.dreamNodeService.update(uuid, updates);
385  			new Notice(`Contact updated: ${name || dreamerNode.name}'s DID received`);
386  
387  			// Auto-trigger sync for mutual delegation
388  			try {
389  				const executed = (this.plugin.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following');
390  				if (executed) {
391  					new Notice('Collaboration setup complete! Syncing peer configuration...');
392  				} else {
393  					new Notice('Contact updated. Run "Sync Radicle Peer Following" to complete setup.');
394  				}
395  			} catch {
396  				new Notice('Contact updated, but auto-sync failed. Run "Sync Radicle Peer Following" manually.');
397  			}
398  
399  			// Refresh UI
400  			await this.dreamNodeService.scanVault();
401  
402  		} catch (error) {
403  			console.error('[URIHandler] Failed to handle update contact:', error);
404  			new Notice(`Failed to update contact: ${error instanceof Error ? error.message : 'Unknown error'}`);
405  		}
406  	}
407  
408  	/**
409  	 * Classify identifier type for universal clone support
410  	 */
411  	private classifyIdentifier(id: string): 'radicle' | 'github' | 'uuid' {
412  		if (id.startsWith('rad:')) return 'radicle';
413  		if (id.includes('github.com/')) return 'github';
414  		return 'uuid';
415  	}
416  
417  	/**
418  	 * Auto-focus a node after clone
419  	 */
420  	private async autoFocusNode(repoName: string, silent: boolean = false): Promise<void> {
421  		const allNodes = await this.dreamNodeService.list();
422  		const targetNode = allNodes.find((node: any) => node.repoPath === repoName);
423  
424  		if (!targetNode) return;
425  
426  		const store = useInterBrainStore.getState();
427  		store.setSelectedNode(targetNode);
428  		store.requestNavigation({ type: 'liminal-web-focus', nodeId: targetNode.id });
429  
430  		if (!silent) {
431  			new Notice(`Node focused in DreamSpace!`);
432  		}
433  	}
434  
435  	/**
436  	 * Index a newly cloned node for semantic search
437  	 */
438  	private async indexNewNode(repoName: string): Promise<void> {
439  		try {
440  			const allNodes = await this.dreamNodeService.list();
441  			const targetNode = allNodes.find((node: any) => node.repoPath === repoName);
442  
443  			if (!targetNode) return;
444  
445  			const { indexingService } = await import('../semantic-search/services/indexing-service');
446  			await indexingService.indexNode(targetNode);
447  		} catch {
448  			// Non-critical - don't fail clone
449  		}
450  	}
451  
452  	/**
453  	 * Normalize repository name to human-readable title
454  	 * Uses the same logic as DreamNodeMigrationService.normalizeToHumanReadable()
455  	 *
456  	 * Handles:
457  	 * - PascalCase: "ThunderstormGenerator" → "Thunderstorm Generator"
458  	 * - kebab-case: "thunderstorm-generator" → "Thunderstorm Generator"
459  	 * - snake_case: "thunderstorm_generator" → "Thunderstorm Generator"
460  	 * - Mixed: "Thunderstorm-Generator-UPDATED" → "Thunderstorm Generator Updated"
461  	 */
462  	private async normalizeRepoNameToTitle(repoName: string): Promise<string> {
463  		const { isPascalCase, pascalCaseToTitle } = await import('../dreamnode/utils/title-sanitization');
464  
465  		// If repo name contains hyphens, underscores, or periods as separators
466  		if (/[-_.]+/.test(repoName)) {
467  			// Replace separators with spaces and normalize
468  			return repoName
469  				.split(/[-_.]+/)                    // Split on hyphens, underscores, periods
470  				.filter(word => word.length > 0)
471  				.map(word => {
472  					// Capitalize first letter, lowercase rest (proper title case)
473  					const cleaned = word.trim();
474  					if (cleaned.length === 0) return '';
475  					return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
476  				})
477  				.join(' ')
478  				.trim();
479  		}
480  
481  		// If repo name is pure PascalCase (no separators), convert to spaced format
482  		if (isPascalCase(repoName)) {
483  			return pascalCaseToTitle(repoName);
484  		}
485  
486  		// Already human-readable with spaces, return as-is
487  		return repoName;
488  	}
489  
490  	/**
491  	 * Ensure Radicle node is running before clone operations
492  	 * @returns Passphrase string, empty if node running, or null if not configured
493  	 */
494  	private async ensureRadicleNodeRunning(): Promise<string | null> {
495  		const { PassphraseManager } = await import('../social-resonance-filter/services/passphrase-manager');
496  		const { UIService } = await import('../../core/services/ui-service');
497  
498  		const uiService = new UIService(this.app);
499  		const passphraseManager = new PassphraseManager(uiService, this.plugin);
500  		const passphrase = await passphraseManager.getPassphrase();
501  
502  		if (passphrase === null) {
503  			new Notice('Please configure your Radicle passphrase in settings and try again');
504  			return null;
505  		}
506  
507  		if (passphrase === '') {
508  			return ''; // Node already running
509  		}
510  
511  		// Ensure Radicle CLI is available before starting node
512  		const isAvailable = await this.radicleService.isAvailable();
513  		if (!isAvailable) {
514  			throw new Error('Radicle CLI not available');
515  		}
516  
517  		// Start node
518  		try {
519  			await (this.radicleService as any).startNode(passphrase);
520  			new Notice('Radicle node started');
521  			return passphrase;
522  		} catch (error) {
523  			console.error('[URIHandler] Failed to start Radicle node:', error);
524  			new Notice(`Failed to start Radicle node: ${error instanceof Error ? error.message : 'Unknown error'}`);
525  			throw error;
526  		}
527  	}
528  
529  	/**
530  	 * Clone a DreamNode from Radicle network
531  	 * Public method to allow reuse by CoherenceBeaconService and other features
532  	 */
533  	/**
534  	 * Clone a DreamNode from Radicle network
535  	 * @param radicleId The Radicle ID (RID) of the repo to clone
536  	 * @param silent If true, don't show notices
537  	 * @param peerNid Optional peer Node ID for direct P2P clone (bypasses routing table)
538  	 */
539  	public async cloneFromRadicle(radicleId: string, silent: boolean = false, peerNid?: string): Promise<{ status: 'success' | 'skipped' | 'error'; repoName?: string }> {
540  		try {
541  			const adapter = this.app.vault.adapter as any;
542  			const vaultPath = adapter.basePath || '';
543  
544  			if (!vaultPath) {
545  				throw new Error('Could not determine vault path');
546  			}
547  
548  			// Ensure Radicle node is running before attempting clone
549  			const passphrase = await this.ensureRadicleNodeRunning();
550  			if (passphrase === null) {
551  				throw new Error('Radicle node requires passphrase to start. Operation cancelled.');
552  			}
553  
554  			if (!silent) {
555  				new Notice(`Cloning from Radicle network...`, 3000);
556  			}
557  
558  			// RadicleService.clone() handles: clone, directory rename, submodule init, .udd update
559  			// Pass peerNid for direct P2P clone (--seed flag)
560  			const cloneResult = await this.radicleService.clone(radicleId, vaultPath, passphrase, peerNid);
561  
562  			if (cloneResult.alreadyExisted) {
563  				console.log(`[URIHandler] Radicle ID ${radicleId} already exists as "${cloneResult.repoName}"`);
564  				if (!silent) {
565  					new Notice(`DreamNode "${cloneResult.repoName}" already cloned!`);
566  					await this.autoFocusNode(cloneResult.repoName, silent);
567  				}
568  				return { status: 'skipped', repoName: cloneResult.repoName };
569  			}
570  
571  			if (!silent) {
572  				new Notice(`Cloned "${cloneResult.repoName}" successfully!`);
573  			}
574  
575  			// Auto-refresh: Make the newly cloned node appear immediately
576  			if (!silent) {
577  				try {
578  					await this.dreamNodeService.scanVault();
579  					await this.indexNewNode(cloneResult.repoName);
580  
581  					const relationshipService = new DreamSongRelationshipService(this.plugin);
582  					const scanResult = await relationshipService.scanVaultForDreamSongRelationships();
583  
584  					if (scanResult.success) {
585  						const store = useInterBrainStore.getState();
586  						store.requestNavigation({ type: 'applyLayout' });
587  						setTimeout(() => this.autoFocusNode(cloneResult.repoName, silent), 100);
588  					}
589  				} catch {
590  					// Non-critical - node was cloned successfully
591  				}
592  			}
593  
594  			return { status: 'success', repoName: cloneResult.repoName };
595  
596  		} catch (error) {
597  			// Handle network propagation delays gracefully (SEED-RELAYED MODE)
598  			// This happens when the sender hasn't announced to seeds yet,
599  			// or seeds haven't propagated the repo information
600  			if (error instanceof Error && error.message === 'NETWORK_PROPAGATION_DELAY') {
601  				if (!silent) {
602  					new Notice(
603  						'DreamNode not yet available on network. The sender may need to sync their node, or network propagation is in progress. Please try again in a moment.',
604  						10000
605  					);
606  				}
607  				return { status: 'error' };
608  			}
609  
610  			console.error(`[URIHandler] Clone failed for ${radicleId}:`, error);
611  
612  			if (!silent) {
613  				const errorMsg = error instanceof Error ? error.message : 'Unknown error';
614  				new Notice(`Failed to clone: ${errorMsg}`);
615  			}
616  
617  			return { status: 'error' };
618  		}
619  	}
620  
621  	/**
622  	 * Clone a DreamNode from GitHub
623  	 */
624  	private async cloneFromGitHub(repoPath: string, silent: boolean = false): Promise<'success' | 'skipped' | 'error'> {
625  		try {
626  			const adapter = this.app.vault.adapter as any;
627  			const vaultPath = adapter.basePath || '';
628  
629  			if (!vaultPath) {
630  				throw new Error('Could not determine vault path');
631  			}
632  
633  			// Extract repo name from path
634  			const match = repoPath.match(/github\.com\/[^/]+\/([^/\s]+)/);
635  			if (!match) {
636  				throw new Error(`Invalid GitHub repository path: ${repoPath}`);
637  			}
638  
639  			const repoName = match[1].replace(/\.git$/, '');
640  			const destinationPath = `${vaultPath}/${repoName}`;
641  
642  			// Check if already exists - use Obsidian vault API
643  			if (await this.app.vault.adapter.exists(repoName)) {
644  				if (!silent) {
645  					new Notice(`DreamNode "${repoName}" already cloned!`);
646  					await this.autoFocusNode(repoName, silent);
647  				}
648  				return 'skipped';
649  			}
650  
651  			if (!silent) {
652  				new Notice(`Cloning from GitHub...`, 3000);
653  			}
654  
655  			// Clone via GitHub service
656  			const { githubService } = await import('../github-publishing/services/github-service');
657  			const githubUrl = `https://${repoPath}`;
658  			await githubService.clone(githubUrl, destinationPath);
659  
660  			// Create .udd file for InterBrain compatibility using UDDService
661  			try {
662  				if (!UDDService.uddExists(destinationPath)) {
663  					// Use Web Crypto API for UUID generation (available in Electron)
664  					const uuid = globalThis.crypto.randomUUID();
665  					const title = await this.normalizeRepoNameToTitle(repoName);
666  
667  					await UDDService.createUDD(destinationPath, {
668  						uuid,
669  						title,
670  						type: 'dream',
671  						dreamTalk: ''
672  					});
673  
674  					// Add GitHub URL to the UDD (createUDD doesn't support this field)
675  					const udd = await UDDService.readUDD(destinationPath);
676  					(udd as any).githubRepoUrl = githubUrl;
677  					await UDDService.writeUDD(destinationPath, udd);
678  				}
679  			} catch {
680  				// Non-critical - clone succeeded
681  			}
682  
683  			if (!silent) {
684  				new Notice(`Cloned "${repoName}" successfully!`);
685  			}
686  
687  			// Auto-refresh
688  			if (!silent) {
689  				try {
690  					await this.dreamNodeService.scanVault();
691  					await this.indexNewNode(repoName);
692  
693  					const relationshipService = new DreamSongRelationshipService(this.plugin);
694  					const scanResult = await relationshipService.scanVaultForDreamSongRelationships();
695  
696  					if (scanResult.success) {
697  						const store = useInterBrainStore.getState();
698  						store.requestNavigation({ type: 'applyLayout' });
699  						setTimeout(() => this.autoFocusNode(repoName, silent), 100);
700  					}
701  				} catch {
702  					// Non-critical
703  				}
704  			}
705  
706  			return 'success';
707  
708  		} catch (error) {
709  			console.error(`[URIHandler] GitHub clone failed for ${repoPath}:`, error);
710  
711  			if (!silent) {
712  				const errorMsg = error instanceof Error ? error.message : 'Unknown error';
713  				new Notice(`Failed to clone from GitHub: ${errorMsg}`);
714  			}
715  
716  			return 'error';
717  		}
718  	}
719  
720  	/**
721  	 * Handle collaboration handshake: create Dreamer node for sender and link cloned node
722  	 */
723  	private async handleCollaborationHandshake(
724  		clonedNodeIdentifier: string,
725  		senderDid: string,
726  		senderName: string
727  	): Promise<DreamNode | undefined> {
728  		try {
729  			const dreamerNode = await this.findOrCreateDreamerNode(senderDid, senderName);
730  			await new Promise(resolve => setTimeout(resolve, 200));
731  
732  			const clonedNode = await this.findNodeByIdentifier(clonedNodeIdentifier);
733  			if (!clonedNode) return;
734  
735  			await this.linkNodes(clonedNode, dreamerNode);
736  
737  			// Refresh UI
738  			try {
739  				await this.dreamNodeService.scanVault();
740  				const relationshipService = new DreamSongRelationshipService(this.plugin);
741  				const scanResult = await relationshipService.scanVaultForDreamSongRelationships();
742  
743  				if (scanResult.success) {
744  					const store = useInterBrainStore.getState();
745  					store.requestNavigation({ type: 'applyLayout' });
746  				}
747  			} catch {
748  				// Non-critical
749  			}
750  
751  			return dreamerNode;
752  		} catch {
753  			return undefined;
754  		}
755  	}
756  
757  	/**
758  	 * Find existing Dreamer node by DID, or create new one
759  	 */
760  	private async findOrCreateDreamerNode(did: string, name: string, email?: string): Promise<any> {
761  		// Search for existing Dreamer node with this DID
762  		const allNodes = await this.dreamNodeService.list();
763  		const existingDreamer = allNodes.find((node: any) => {
764  			return node.type === 'dreamer' && node.did === did;
765  		});
766  
767  		if (existingDreamer) {
768  			// DreamNode.id is already the UUID from .udd - just alias it for compatibility
769  			(existingDreamer as any).uuid = existingDreamer.id;
770  			return existingDreamer;
771  		}
772  
773  		// Create new Dreamer node with DID metadata using standard creation flow
774  		const metadata: any = { did };
775  		if (email) {
776  			metadata.email = email;
777  		}
778  		const newDreamer = await this.dreamNodeService.create(name, 'dreamer', undefined, undefined, undefined, metadata);
779  
780  		// Wait for creation to complete
781  		await new Promise(resolve => setTimeout(resolve, 500));
782  
783  		// DreamNode.id is already the UUID - just alias it for compatibility
784  		(newDreamer as any).uuid = newDreamer.id;
785  
786  		return newDreamer;
787  	}
788  
789  	/**
790  	 * Find node by identifier (Radicle ID or GitHub URL)
791  	 * Uses DreamNode properties from store (populated from .udd during vault scan)
792  	 */
793  	private async findNodeByIdentifier(identifier: string): Promise<any> {
794  		const allNodes = await this.dreamNodeService.list();
795  
796  		// Determine if identifier is Radicle ID or GitHub URL
797  		const isRadicleId = identifier.startsWith('rad:');
798  		const isGitHubUrl = identifier.includes('github.com/');
799  
800  		// Normalize GitHub URL if needed (remove protocol, .git suffix)
801  		const normalizedGitHubUrl = isGitHubUrl
802  			? identifier.replace(/^https?:\/\//, '').replace(/\.git$/, '')
803  			: null;
804  
805  		for (const node of allNodes) {
806  			// Check Radicle ID (available on DreamNode from vault scan)
807  			if (isRadicleId && node.radicleId === identifier) {
808  				(node as any).uuid = node.id;
809  				return node;
810  			}
811  
812  			// Check GitHub URL (available on DreamNode from vault scan)
813  			if (isGitHubUrl && node.githubRepoUrl) {
814  				const normalizedUddUrl = node.githubRepoUrl.replace(/^https?:\/\//, '').replace(/\.git$/, '');
815  				if (normalizedUddUrl === normalizedGitHubUrl) {
816  					(node as any).uuid = node.id;
817  					return node;
818  				}
819  			}
820  		}
821  
822  		return null;
823  	}
824  
825  	/**
826  	 * Link two nodes by adding relationship
827  	 * Delegates to GitDreamNodeService.addRelationship for proper bidirectional handling
828  	 */
829  	private async linkNodes(sourceNode: any, targetNode: any): Promise<void> {
830  		// Use node.id (which is the UUID) - the uuid alias is just for backward compat
831  		const sourceId = sourceNode.uuid || sourceNode.id;
832  		const targetId = targetNode.uuid || targetNode.id;
833  
834  		if (!sourceId || !targetId) {
835  			throw new Error(`Cannot link nodes without IDs`);
836  		}
837  
838  		// Delegate to GitDreamNodeService which handles bidirectional relationships
839  		// and liminal-web.json updates for Dreamer nodes
840  		await this.dreamNodeService.addRelationship(sourceId, targetId);
841  	}
842  
843  	/**
844  	 * Generate deep link URL for single DreamNode with collaboration handshake
845  	 * @param vaultName The Obsidian vault name (unused, kept for API compatibility)
846  	 * @param identifier Radicle ID (preferred) or UUID (fallback)
847  	 * @param senderDid Optional sender's Radicle DID for peer following
848  	 * @param senderName Optional sender's human-readable name for Dreamer node creation
849  	 * @param senderEmail Optional sender's email address for contact info
850  	 */
851  	static generateSingleNodeLink(vaultName: string, identifier: string, senderDid?: string, senderName?: string, senderEmail?: string): string {
852  		// Unified schema: Use ?ids= for both single and batch clones
853  		// Don't encode colons in Radicle IDs - they're part of the protocol
854  		// rad:z... should stay as rad:z..., not rad%3Az...
855  		const encodedIdentifier = identifier.startsWith('rad:')
856  			? identifier // Keep Radicle ID as-is
857  			: encodeURIComponent(identifier); // Encode other identifiers (UUIDs)
858  
859  		let uri = `obsidian://interbrain-clone?ids=${encodedIdentifier}`;
860  
861  		// Add collaboration handshake parameters if provided
862  		if (senderDid) {
863  			uri += `&senderDid=${encodeURIComponent(senderDid)}`;
864  		}
865  		if (senderName) {
866  			uri += `&senderName=${encodeURIComponent(senderName)}`;
867  		}
868  		if (senderEmail) {
869  			uri += `&senderEmail=${encodeURIComponent(senderEmail)}`;
870  		}
871  
872  		return uri;
873  	}
874  
875  	/**
876  	 * Generate deep link URL for GitHub clone (uses unified ?ids= schema)
877  	 * @param vaultName The Obsidian vault name (unused, kept for API compatibility)
878  	 * @param githubRepoUrl GitHub repository URL (e.g., "https://github.com/user/repo" or "github.com/user/repo")
879  	 */
880  	static generateGitHubCloneLink(vaultName: string, githubRepoUrl: string): string {
881  		// Extract clean repo path: github.com/user/repo
882  		const repoPath = githubRepoUrl
883  			.replace(/^https?:\/\//, '')  // Remove protocol
884  			.replace(/\.git$/, '');       // Remove .git suffix
885  
886  		// Use unified ?ids= schema (not ?repo=)
887  		return `obsidian://interbrain-clone?ids=${repoPath}`;
888  	}
889  
890  	/**
891  	 * Generate deep link URL for batch clone with collaboration handshake
892  	 * @param vaultName The Obsidian vault name (unused, kept for API compatibility)
893  	 * @param identifiers Array of identifiers (can be Radicle IDs, GitHub URLs, or UUIDs)
894  	 * @param senderDid Optional sender's Radicle DID for peer following
895  	 * @param senderName Optional sender's human-readable name for Dreamer node creation
896  	 */
897  	static generateBatchNodeLink(vaultName: string, identifiers: string[], senderDid?: string, senderName?: string, senderEmail?: string): string {
898  		// Unified schema: Use ?ids= with comma-separated list
899  		const encodedIdentifiers = encodeURIComponent(identifiers.join(','));
900  		let uri = `obsidian://interbrain-clone?ids=${encodedIdentifiers}`;
901  
902  		// Add collaboration handshake parameters if provided
903  		if (senderDid) {
904  			uri += `&senderDid=${encodeURIComponent(senderDid)}`;
905  		}
906  		if (senderName) {
907  			uri += `&senderName=${encodeURIComponent(senderName)}`;
908  		}
909  		if (senderEmail) {
910  			uri += `&senderEmail=${encodeURIComponent(senderEmail)}`;
911  		}
912  
913  		return uri;
914  	}
915  
916  	/**
917  	 * Generate update-contact URI for DID backpropagation
918  	 * @param did Sender's Radicle DID
919  	 * @param dreamerUuid UUID of the recipient's Dreamer node (for the sender)
920  	 * @param name Optional sender's name
921  	 * @param email Optional sender's email
922  	 *
923  	 * Example: Bob installs InterBrain and wants to share his DID with Alice
924  	 * - did: Bob's newly created Radicle DID
925  	 * - dreamerUuid: Alice's UUID for her Dreamer node representing Bob
926  	 * - name: "Bob" (optional, for display)
927  	 * - email: "bob@example.com" (optional, for additional contact info)
928  	 */
929  	static generateUpdateContactLink(did: string, dreamerUuid: string, name?: string, email?: string): string {
930  		let uri = `obsidian://interbrain-update-contact?did=${encodeURIComponent(did)}&uuid=${encodeURIComponent(dreamerUuid)}`;
931  
932  		if (name) {
933  			uri += `&name=${encodeURIComponent(name)}`;
934  		}
935  		if (email) {
936  			uri += `&email=${encodeURIComponent(email)}`;
937  		}
938  
939  		return uri;
940  	}
941  }
942  
943  // Singleton instance
944  let _uriHandlerService: URIHandlerService | null = null;
945  
946  export function initializeURIHandlerService(app: App, plugin: Plugin, radicleService: RadicleService, dreamNodeService: GitDreamNodeService): void {
947  	_uriHandlerService = new URIHandlerService(app, plugin, radicleService, dreamNodeService);
948  	_uriHandlerService.registerHandlers();
949  }
950  
951  export function getURIHandlerService(): URIHandlerService {
952  	if (!_uriHandlerService) {
953  		throw new Error('URIHandlerService not initialized. Call initializeURIHandlerService() first.');
954  	}
955  	return _uriHandlerService;
956  }