diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 2ee3569a8..18ce801a0 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -4,6 +4,7 @@ import { DropZone } from './components/DropZone'; import { LoadingOverlay } from './components/LoadingOverlay'; import { Header } from './components/Header'; import { GraphCanvas, GraphCanvasHandle } from './components/GraphCanvas'; +import { RepoLegend } from './components/RepoLegend'; import { RightPanel } from './components/RightPanel'; import { SettingsPanel } from './components/SettingsPanel'; import { StatusBar } from './components/StatusBar'; @@ -45,6 +46,7 @@ const AppContent = () => { setAvailableRepos, switchRepo, setCurrentRepo, + connectToGroup, } = useAppState(); const graphCanvasRef = useRef(null); @@ -228,6 +230,13 @@ const AppContent = () => { window.history.replaceState(null, '', url.toString()); } }} + onGroupSelect={(groupName, serverUrl) => { + if (serverUrl && !serverBaseUrl) { + const normalized = normalizeServerUrl(serverUrl); + setServerBaseUrl(normalized); + } + connectToGroup(groupName); + }} /> ); } @@ -282,6 +291,7 @@ const AppContent = () => { {/* Graph area - takes remaining space */}
+ {/* Code References Panel (overlay) - does NOT resize the graph, it overlaps on top */} {isCodePanelOpen && (codeReferences.length > 0 || !!selectedNode) && ( diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index f268eb656..ba7101c82 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -13,6 +13,7 @@ import { RepoLanding } from './RepoLanding'; interface DropZoneProps { onServerConnect?: (result: ConnectResult, serverUrl?: string) => void | Promise; + onGroupSelect?: (groupName: string, serverUrl?: string) => void; } // ── Crossfade wrapper ─────────────────────────────────────────────────────── @@ -133,7 +134,7 @@ function LoadingCard({ message }: { message: string }) { // ── DropZone ───────────────────────────────────────────────────────────────── -export const DropZone = ({ onServerConnect }: DropZoneProps) => { +export const DropZone = ({ onServerConnect, onGroupSelect }: DropZoneProps) => { const [error, setError] = useState(null); // Backend polling for server detection @@ -305,6 +306,9 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { onGroupSelect(groupName, detectedBackendUrl ?? undefined) + : undefined} onAnalyzeComplete={connectToRepo} /> )} diff --git a/gitnexus-web/src/components/FileTreePanel.tsx b/gitnexus-web/src/components/FileTreePanel.tsx index 98c20c68a..54fee08e2 100644 --- a/gitnexus-web/src/components/FileTreePanel.tsx +++ b/gitnexus-web/src/components/FileTreePanel.tsx @@ -141,7 +141,12 @@ const TreeItem = ({ )} {/* Node icon */} - {node.type === 'folder' ? ( + {node.path.startsWith('repo::') ? ( + <> + + + + ) : node.type === 'folder' ? ( isExpanded ? ( ) : ( @@ -152,7 +157,9 @@ const TreeItem = ({ )} {/* Name */} - {node.name} + + {node.name} + {/* Children */} @@ -222,6 +229,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { openCodePanel, depthFilter, setDepthFilter, + groupMode, } = useAppState(); const [isCollapsed, setIsCollapsed] = useState(false); @@ -229,11 +237,38 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { const [expandedPaths, setExpandedPaths] = useState>(new Set()); const [activeTab, setActiveTab] = useState<'files' | 'filters'>('files'); - // Build file tree from graph + // Build file tree from graph (with repo grouping in group mode) const fileTree = useMemo(() => { if (!graph) return []; - return buildFileTree(graph.nodes); - }, [graph]); + + if (!groupMode) return buildFileTree(graph.nodes); + + // Group nodes by repo, then build a tree per repo + const repoGroups = new Map(); + for (const node of graph.nodes) { + const repo = (node.properties as Record)._repo as string | undefined; + const repoName = repo || 'unknown'; + const group = repoGroups.get(repoName); + if (group) { + group.push(node); + } else { + repoGroups.set(repoName, [node]); + } + } + + const repoTrees: TreeNode[] = []; + for (const [repoName, nodes] of repoGroups) { + const children = buildFileTree(nodes); + repoTrees.push({ + id: `repo::${repoName}`, + name: repoName, + type: 'folder', + path: `repo::${repoName}`, + children, + }); + } + return repoTrees; + }, [graph, groupMode]); // Auto-expand first level on initial load useEffect(() => { diff --git a/gitnexus-web/src/components/GraphCanvas.tsx b/gitnexus-web/src/components/GraphCanvas.tsx index e34fd434c..eb11ad771 100644 --- a/gitnexus-web/src/components/GraphCanvas.tsx +++ b/gitnexus-web/src/components/GraphCanvas.tsx @@ -46,8 +46,10 @@ export const GraphCanvas = forwardRef((_, ref) => { clearAICitationHighlights, clearBlastRadius, animatedNodes, + highlightedRepos, } = useAppState(); const [hoveredNodeName, setHoveredNodeName] = useState(null); + const [hoveredNodeRepo, setHoveredNodeRepo] = useState(null); const effectiveHighlightedNodeIds = useMemo(() => { if (!isAIHighlightsEnabled) return highlightedNodeIds; @@ -96,10 +98,13 @@ export const GraphCanvas = forwardRef((_, ref) => { (nodeId: string | null) => { if (!nodeId || !graph) { setHoveredNodeName(null); + setHoveredNodeRepo(null); return; } const node = nodeById.get(nodeId); setHoveredNodeName(node ? node.properties.name : null); + const repo = node ? (node.properties as Record)._repo as string | undefined : undefined; + setHoveredNodeRepo(repo ?? null); }, [graph, nodeById], ); @@ -147,6 +152,7 @@ export const GraphCanvas = forwardRef((_, ref) => { blastRadiusNodeIds: effectiveBlastRadiusNodeIds, animatedNodes: effectiveAnimatedNodes, visibleEdgeTypes, + highlightedRepos, }); // Expose focusNode to parent via ref @@ -253,6 +259,9 @@ export const GraphCanvas = forwardRef((_, ref) => { {hoveredNodeName && !sigmaSelectedNode && (
{hoveredNodeName} + {hoveredNodeRepo && ( + ({hoveredNodeRepo}) + )}
)} diff --git a/gitnexus-web/src/components/RepoLanding.tsx b/gitnexus-web/src/components/RepoLanding.tsx index e6a44a78e..00d2daf3c 100644 --- a/gitnexus-web/src/components/RepoLanding.tsx +++ b/gitnexus-web/src/components/RepoLanding.tsx @@ -12,9 +12,10 @@ * └─ RepoAnalyzer (variant="onboarding") */ -import { Sparkles, ArrowRight, GitBranch, FileCode, Layers } from '@/lib/lucide-icons'; +import { Sparkles, ArrowRight, GitBranch, FileCode, Layers, FolderOpen } from '@/lib/lucide-icons'; +import { useState, useEffect } from 'react'; import { RepoAnalyzer } from './RepoAnalyzer'; -import type { BackendRepo } from '../services/backend-client'; +import { fetchGroups, fetchGroupStatus, type BackendRepo, type GroupStatus } from '../services/backend-client'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -83,18 +84,92 @@ function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void }) ); } +// ── Group card ────────────────────────────────────────────────────────────── + +function GroupCard({ + name, + status, + onClick, +}: { + name: string; + status: GroupStatus | null; + onClick: () => void; +}) { + const repoCount = status ? Object.keys(status.repos).length : 0; + const lastSync = status?.lastSync; + + return ( + + ); +} + // ── RepoLanding ────────────────────────────────────────────────────────────── +type LandingTab = 'repos' | 'groups'; + interface RepoLandingProps { repos: BackendRepo[]; onSelectRepo: (repoName: string) => void; + onSelectGroup?: (groupName: string) => void; onAnalyzeComplete: (repoName: string) => void; } -export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => { +export const RepoLanding = ({ + repos, + onSelectRepo, + onSelectGroup, + onAnalyzeComplete, +}: RepoLandingProps) => { + const [activeTab, setActiveTab] = useState('repos'); + const [groups, setGroups] = useState([]); + const [groupStatuses, setGroupStatuses] = useState>({}); + + useEffect(() => { + fetchGroups() + .then((g) => setGroups(g)) + .catch(() => setGroups([])); + }, []); + + useEffect(() => { + if (activeTab !== 'groups' || groups.length === 0) return; + for (const name of groups) { + if (groupStatuses[name] !== undefined) continue; + setGroupStatuses((prev) => ({ ...prev, [name]: null })); + fetchGroupStatus(name) + .then((s) => setGroupStatuses((prev) => ({ ...prev, [name]: s }))) + .catch(() => {}); + } + }, [activeTab, groups, groupStatuses]); + return ( -
- {/* Ambient glows — mirrors OnboardingGuide aesthetic */} +
+ {/* Ambient glows */}
@@ -109,34 +184,88 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand

- Choose a repository + {activeTab === 'repos' ? 'Choose a repository' : 'Choose a group'}

- Select an indexed repository to explore, or analyze a new one. + {activeTab === 'repos' + ? 'Select an indexed repository to explore, or analyze a new one.' + : 'Select a group to view the combined knowledge graph across repos.'}

- {/* Repo list */} -
- {repos.map((repo) => ( - onSelectRepo(repo.name)} /> - ))} -
+ {/* Tab bar */} + {groups.length > 0 && ( +
+ + +
+ )} - {/* Divider */} -
-
- - or analyze new - -
-
+ {/* Repos tab content */} + {activeTab === 'repos' && ( + <> +
+ {repos.map((repo) => ( + onSelectRepo(repo.name)} /> + ))} +
- {/* Analyzer form */} -
- -
+
+
+ + or analyze new + +
+
+ +
+ +
+ + )} + + {/* Groups tab content */} + {activeTab === 'groups' && ( +
+ {groups.length === 0 ? ( +
+ +

No groups configured yet.

+

+ Run gitnexus group auto-discover to create one. +

+
+ ) : ( + groups.map((name) => ( + onSelectGroup?.(name)} + /> + )) + )} +
+ )} {/* Footer hint */}

diff --git a/gitnexus-web/src/components/RepoLegend.tsx b/gitnexus-web/src/components/RepoLegend.tsx new file mode 100644 index 000000000..2897f97bb --- /dev/null +++ b/gitnexus-web/src/components/RepoLegend.tsx @@ -0,0 +1,69 @@ +import { useAppState } from '../hooks/useAppState'; +import { useMemo, useCallback } from 'react'; + +export const RepoLegend = () => { + const { graph, groupMode, highlightedRepos, setHighlightedRepos } = useAppState(); + + const repos = useMemo(() => { + if (!groupMode || !graph) return []; + + const repoMap = new Map(); + for (const node of graph.nodes) { + const repo = (node.properties as Record)._repo as string | undefined; + if (!repo) continue; + const existing = repoMap.get(repo); + if (existing) { + existing.count++; + } else { + repoMap.set(repo, { color: '#9ca3af', count: 1 }); + } + } + return [...repoMap.entries()].map(([name, info]) => ({ name, ...info })); + }, [graph, groupMode]); + + const toggleRepo = useCallback( + (repoName: string) => { + const next = new Set(highlightedRepos); + if (next.has(repoName)) { + next.delete(repoName); + } else { + next.add(repoName); + } + setHighlightedRepos(next); + }, + [highlightedRepos, setHighlightedRepos], + ); + + if (!groupMode || repos.length === 0) return null; + + return ( +

+
+ Repos +
+
+ {repos.map((repo) => { + const isHighlighted = highlightedRepos.has(repo.name); + return ( + + ); + })} +
+
+ ); +}; diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index d3a83e4d4..2eed988eb 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -155,6 +155,8 @@ interface AppState { availableGroups: string[]; setAvailableGroups: (groups: string[]) => void; connectToGroup: (groupName: string) => Promise; + highlightedRepos: Set; + setHighlightedRepos: (repos: Set) => void; // Worker API (shared across app) runQuery: (cypher: string) => Promise; @@ -332,6 +334,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const [groupMode, setGroupMode] = useState(false); const [activeGroup, setActiveGroup] = useState(null); const [availableGroups, setAvailableGroups] = useState([]); + const [highlightedRepos, setHighlightedRepos] = useState>(new Set()); // Embedding state const [embeddingStatus, setEmbeddingStatus] = useState('idle'); @@ -1188,8 +1191,6 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const connectToGroup = useCallback( async (groupName: string) => { - if (!serverBaseUrl) return; - setProgress({ phase: 'extracting', percent: 10, @@ -1229,6 +1230,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setActiveGroup(groupName); setProjectName(`Group: ${groupName}`); + // Populate highlightedRepos with all repos (all visible by default) + const repoNames = new Set(); + for (const node of result.nodes) { + const repo = (node.properties as Record)._repo as string | undefined; + if (repo) repoNames.add(repo); + } + setHighlightedRepos(repoNames); + setProgress(null); setViewMode('exploring'); } catch (err) { @@ -1245,7 +1254,6 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { } }, [ - serverBaseUrl, setProgress, setViewMode, setProjectName, @@ -1356,6 +1364,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { availableGroups, setAvailableGroups, connectToGroup, + highlightedRepos, + setHighlightedRepos, runQuery, isDatabaseReady, // Embedding state and methods diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts index 8f350f0d3..c42064736 100644 --- a/gitnexus-web/src/hooks/useSigma.ts +++ b/gitnexus-web/src/hooks/useSigma.ts @@ -62,6 +62,7 @@ interface UseSigmaOptions { blastRadiusNodeIds?: Set; animatedNodes?: Map; visibleEdgeTypes?: EdgeType[]; + highlightedRepos?: Set; } interface UseSigmaReturn { @@ -138,6 +139,7 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const blastRadiusRef = useRef>(new Set()); const animatedNodesRef = useRef>(new Map()); const visibleEdgeTypesRef = useRef(null); + const highlightedReposRef = useRef | null>(null); const layoutTimeoutRef = useRef | null>(null); const animationFrameRef = useRef(null); const [isLayoutRunning, setIsLayoutRunning] = useState(false); @@ -148,12 +150,14 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { blastRadiusRef.current = options.blastRadiusNodeIds || new Set(); animatedNodesRef.current = options.animatedNodes || new Map(); visibleEdgeTypesRef.current = options.visibleEdgeTypes || null; + highlightedReposRef.current = options.highlightedRepos || null; sigmaRef.current?.refresh(); }, [ options.highlightedNodeIds, options.blastRadiusNodeIds, options.animatedNodes, options.visibleEdgeTypes, + options.highlightedRepos, ]); // Animation loop for node effects @@ -283,6 +287,21 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { return res; } + // Dim nodes in deselected repos (group mode repo filtering) + const hRepos = highlightedReposRef.current; + if (hRepos) { + const nodeRepo = data.repoName; + if (nodeRepo && !hRepos.has(nodeRepo)) { + res.color = dimColor(data.color, 0.12); + res.size = (data.size || 8) * 0.35; + res.zIndex = -1; + return res; + } + if (nodeRepo) { + res.zIndex = 2; + } + } + const currentSelected = selectedNodeRef.current; const highlighted = highlightedRef.current; const blastRadius = blastRadiusRef.current; @@ -402,6 +421,26 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { } } + // Dim edges touching deselected repos + const hReposEdge = highlightedReposRef.current; + if (hReposEdge) { + const graph = graphRef.current; + if (graph) { + const [source, target] = graph.extremities(edge); + const srcRepo = graph.getNodeAttribute(source, 'repoName') as string | undefined; + const tgtRepo = graph.getNodeAttribute(target, 'repoName') as string | undefined; + const srcDimmed = srcRepo && !hReposEdge.has(srcRepo); + const tgtDimmed = tgtRepo && !hReposEdge.has(tgtRepo); + if (srcDimmed || tgtDimmed) { + res.color = dimColor(data.color, 0.08); + res.size = 0.2; + res.zIndex = -1; + return res; + } + res.zIndex = 2; + } + } + const currentSelected = selectedNodeRef.current; const highlighted = highlightedRef.current; const blastRadius = blastRadiusRef.current; diff --git a/gitnexus-web/src/lib/graph-adapter.ts b/gitnexus-web/src/lib/graph-adapter.ts index 55059a1ec..d45cf6b4c 100644 --- a/gitnexus-web/src/lib/graph-adapter.ts +++ b/gitnexus-web/src/lib/graph-adapter.ts @@ -216,12 +216,16 @@ export const knowledgeGraphToGraphology = ( const baseSize = NODE_SIZES[node.label] || 8; const scaledSize = getScaledNodeSize(baseSize, nodeCount); - // Structural nodes keep their type-based color + // In multi-repo mode, color File/Folder nodes by repo for visual distinction + const structColor = isMultiRepo && repoCenter + ? repoCenter.color + : NODE_COLORS[node.label] || '#9ca3af'; + graph.addNode(node.id, { x, y, size: scaledSize, - color: NODE_COLORS[node.label] || '#9ca3af', + color: structColor, label: node.properties.name, nodeType: node.label, filePath: node.properties.filePath, diff --git a/gitnexus-web/test/unit/group-features.test.ts b/gitnexus-web/test/unit/group-features.test.ts index 1fa315613..c69903db4 100644 --- a/gitnexus-web/test/unit/group-features.test.ts +++ b/gitnexus-web/test/unit/group-features.test.ts @@ -157,6 +157,21 @@ describe('knowledgeGraphToGraphology - multi-repo support', () => { expect(edgeAttr.color).toBe('#f59e0b'); // Amber }); + it('colors File/Folder nodes by repo color in multi-repo mode', () => { + const nodes = [ + makeNode('repoA::folder1', 'Folder', 'src', { _repo: 'repoA' }), + makeNode('repoB::folder1', 'Folder', 'src', { _repo: 'repoB' }), + ]; + const graph = knowledgeGraphToGraphology(makeGraph(nodes, [])); + + const attrA = graph.getNodeAttributes('repoA::folder1') as SigmaNodeAttributes; + const attrB = graph.getNodeAttributes('repoB::folder1') as SigmaNodeAttributes; + // In multi-repo mode, structural nodes should use repo color, not default type color + expect(attrA.color).toBe(attrA.repoColor); + expect(attrB.color).toBe(attrB.repoColor); + expect(attrA.color).not.toBe(attrB.color); + }); + it('works correctly in single-repo mode (no repoName assigned)', () => { const nodes = [ makeNode('fn1', 'Function', 'funcA'), @@ -187,6 +202,45 @@ describe('knowledgeGraphToGraphology - multi-repo support', () => { }); }); +// ── Repo highlight toggle tests ───────────────────────────────────────────── + +describe('repo highlight toggle logic', () => { + it('toggling a repo out of the set removes it', () => { + const initial = new Set(['repoA', 'repoB']); + const next = new Set(initial); + next.delete('repoA'); + expect(next.has('repoA')).toBe(false); + expect(next.has('repoB')).toBe(true); + expect(next.size).toBe(1); + }); + + it('toggling a repo back into the set adds it', () => { + const current = new Set(['repoB']); + const next = new Set(current); + next.add('repoA'); + expect(next.has('repoA')).toBe(true); + expect(next.has('repoB')).toBe(true); + expect(next.size).toBe(2); + }); + + it('nodes in deselected repos should be dimmable based on repoName attribute', () => { + const highlightedRepos = new Set(['repoA']); + const nodeRepoA = 'repoA'; + const nodeRepoB = 'repoB'; + + // repoA is highlighted — should NOT be dimmed + expect(highlightedRepos.has(nodeRepoA)).toBe(true); + // repoB is not highlighted — should be dimmed + expect(highlightedRepos.has(nodeRepoB)).toBe(false); + }); + + it('empty highlightedRepos means no dimming (all visible)', () => { + const highlightedRepos = new Set(); + // When set is empty, the dimming logic should not trigger + expect(highlightedRepos.size).toBe(0); + }); +}); + // ── Backend client type tests ────────────────────────────────────────────── describe('backend-client group types', () => { diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 5be70d279..13a70a11f 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -21,95 +21,112 @@ export function registerGroupCommands(program: Command): void { console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name); }); + // Shared result printer for auto-discover and repos commands + async function printDiscoverResult(raw: unknown, json: boolean): Promise { + const result = raw as { + error?: string; + group?: string; + groupDir?: string; + repos?: Array<{ name: string; packageName: string | null }>; + repoCount?: number; + packageMappings?: Record; + synced?: boolean; + contracts?: number; + crossLinks?: number; + }; + + if (result.error) { + console.error(result.error); + process.exitCode = 1; + return; + } + + if (json) { + console.log(JSON.stringify(raw, null, 2)); + } else { + console.log(`Group "${result.group}" created at ${result.groupDir}\n`); + console.log(`Repos (${result.repoCount}):`); + for (const repo of result.repos || []) { + const pkg = repo.packageName ? ` (${repo.packageName})` : ''; + console.log(` ${repo.name}${pkg}`); + } + const mappings = result.packageMappings || {}; + if (Object.keys(mappings).length > 0) { + console.log(`\nPackage mappings:`); + for (const [pkg, groupPath] of Object.entries(mappings)) { + console.log(` ${pkg} → ${groupPath}`); + } + } + if (result.synced) { + console.log( + `\nSync: ${result.contracts} contracts, ${result.crossLinks} cross-links`, + ); + } + } + } + group .command('auto-discover [directory]') - .description( - 'Auto-discover indexed repos in a directory, or create a group from explicit repo paths', - ) + .description('Scan a directory for indexed repos and create a group') .option('--name ', 'Group name', 'workspace') - .option('--repos ', 'Explicit repo paths (instead of scanning a directory)') .option('--force', 'Overwrite existing group') .option('--skip-sync', 'Create group without running sync') .option('--json', 'JSON output') .action( async ( directory: string | undefined, - opts: { name: string; repos?: string[]; force?: boolean; skipSync?: boolean; json?: boolean }, + opts: { name: string; force?: boolean; skipSync?: boolean; json?: boolean }, ) => { const pathMod = await import('node:path'); const { LocalBackend } = await import('../mcp/local/local-backend.js'); + const resolvedDir = pathMod.resolve(directory || process.cwd()); const backend = new LocalBackend(); try { await backend.init(); + console.log(`Discovering indexed repos in ${resolvedDir}...\n`); - let raw: unknown; - if (opts.repos && opts.repos.length > 0) { - // Explicit repo paths mode - const resolvedPaths = opts.repos.map((p) => pathMod.resolve(p)); - console.log( - `Creating group from ${resolvedPaths.length} repos...\n`, - ); - raw = await backend.getGroupService().groupDiscover({ - repoPaths: resolvedPaths, - name: opts.name, - force: Boolean(opts.force), - skipSync: Boolean(opts.skipSync), - }); - } else { - // Directory scan mode - const resolvedDir = pathMod.resolve(directory || process.cwd()); - console.log( - `Discovering indexed repos in ${resolvedDir}...\n`, - ); - raw = await backend.getGroupService().groupDiscover({ - directory: resolvedDir, - name: opts.name, - force: Boolean(opts.force), - skipSync: Boolean(opts.skipSync), - }); - } + const raw = await backend.getGroupService().groupDiscover({ + directory: resolvedDir, + name: opts.name, + force: Boolean(opts.force), + skipSync: Boolean(opts.skipSync), + }); + await printDiscoverResult(raw, Boolean(opts.json)); + } finally { + await backend.dispose().catch(() => {}); + } + }, + ); - const result = raw as { - error?: string; - group?: string; - groupDir?: string; - repos?: Array<{ name: string; packageName: string | null }>; - repoCount?: number; - packageMappings?: Record; - synced?: boolean; - contracts?: number; - crossLinks?: number; - }; + group + .command('repos ') + .description('Create a group from explicit repo paths') + .option('--name ', 'Group name', 'workspace') + .option('--force', 'Overwrite existing group') + .option('--skip-sync', 'Create group without running sync') + .option('--json', 'JSON output') + .action( + async ( + paths: string[], + opts: { name: string; force?: boolean; skipSync?: boolean; json?: boolean }, + ) => { + const pathMod = await import('node:path'); + const { LocalBackend } = await import('../mcp/local/local-backend.js'); - if (result.error) { - console.error(result.error); - process.exitCode = 1; - return; - } + const resolvedPaths = paths.map((p) => pathMod.resolve(p)); + const backend = new LocalBackend(); + try { + await backend.init(); + console.log(`Creating group from ${resolvedPaths.length} repos...\n`); - if (opts.json) { - console.log(JSON.stringify(raw, null, 2)); - } else { - console.log(`Group "${result.group}" created at ${result.groupDir}\n`); - console.log(`Discovered repos (${result.repoCount}):`); - for (const repo of result.repos || []) { - const pkg = repo.packageName ? ` (${repo.packageName})` : ''; - console.log(` ${repo.name}${pkg}`); - } - const mappings = result.packageMappings || {}; - if (Object.keys(mappings).length > 0) { - console.log(`\nPackage mappings:`); - for (const [pkg, groupPath] of Object.entries(mappings)) { - console.log(` ${pkg} → ${groupPath}`); - } - } - if (result.synced) { - console.log( - `\nSync: ${result.contracts} contracts, ${result.crossLinks} cross-links`, - ); - } - } + const raw = await backend.getGroupService().groupDiscover({ + repoPaths: resolvedPaths, + name: opts.name, + force: Boolean(opts.force), + skipSync: Boolean(opts.skipSync), + }); + await printDiscoverResult(raw, Boolean(opts.json)); } finally { await backend.dispose().catch(() => {}); }