diff --git a/README.md b/README.md index c3d8e08c4..5f2255889 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72 > *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions. -**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models. +**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models. --- @@ -48,7 +48,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72 | | **CLI + MCP** | **Web UI** | | ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ | | **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser | -| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode, Codex | Quick exploration, demos, one-off analysis | +| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis | | **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode | | **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) | | **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) | @@ -84,17 +84,23 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | --------------------- | --- | ------ | -------------------- | -------------- | | **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** | | **Cursor** | Yes | Yes | — | MCP + Skills | +| **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | | **Codex** | Yes | — | — | MCP | > **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits. -### Community Integrations +## Community Integrations -| Agent | Install | Source | -|-------|---------|--------| -| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | +Built by the community — not officially maintained, but worth checking out. + +| Project | Author | Description | +|---------|--------|-------------| +| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` | +| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) | + +> Have a project built on GitNexus? Open a PR to add it here! If you prefer manual configuration: @@ -104,6 +110,12 @@ If you prefer manual configuration: claude mcp add gitnexus -- npx -y gitnexus@latest mcp ``` +**Codex** (full support — MCP + skills): + +```bash +codex mcp add gitnexus -- npx -y gitnexus@latest mcp +``` + **Cursor** (`~/.cursor/mcp.json` — global, works for all projects): ```json @@ -280,7 +292,7 @@ The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAs ## The Problem GitNexus Solves -Tools like **Cursor**, **Claude Code**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure. +Tools like **Cursor**, **Claude Code**, **Codex**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure. **What happens:** diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 2de2a4a34..72b78a5d9 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -40,6 +40,7 @@ const AppContent = () => { availableRepos, setAvailableRepos, switchRepo, + hydrateWorkerFromServer, } = useAppState(); const graphCanvasRef = useRef(null); @@ -157,21 +158,31 @@ const AppContent = () => { // Transition directly to exploring view setViewMode('exploring'); + setProgress(null); - // Initialize agent if LLM is configured - if (getActiveProviderConfig()) { - initializeAgent(projectName); - } + // Hydrate the worker-side DB (LadybugDB + BM25) so Query/Processes/embeddings work + hydrateWorkerFromServer(result.nodes, result.relationships, result.fileContents).then(() => { + // Initialize agent if LLM is configured + if (getActiveProviderConfig()) { + initializeAgent(projectName); + } - // Auto-start embeddings - startEmbeddings().catch((err) => { - if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { - startEmbeddings('wasm').catch(console.warn); - } else { - console.warn('Embeddings auto-start failed:', err); + // Auto-start embeddings (now that LadybugDB is ready) + startEmbeddings().catch((err) => { + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); + }).catch((err) => { + console.warn('Worker hydration failed (non-fatal):', err); + // Still initialize agent even if hydration fails + if (getActiveProviderConfig()) { + initializeAgent(projectName); } }); - }, [setViewMode, setGraph, setFileContents, setProjectName, initializeAgent, startEmbeddings]); + }, [setViewMode, setGraph, setFileContents, setProjectName, setProgress, initializeAgent, startEmbeddings, hydrateWorkerFromServer]); // Auto-connect when ?server query param is present (bookmarkable shortcut) const autoConnectRan = useRef(false); diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index ab823d240..d60475107 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -281,7 +281,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is if (!isOpen) return null; - const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter']; + const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter', 'minimax']; return ( @@ -366,7 +366,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is w-8 h-8 rounded-lg flex items-center justify-center text-lg ${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'} `}> - {provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : '☁️'} + {provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : provider === 'minimax' ? '⚡' : '☁️'} {getProviderDisplayName(provider)} @@ -814,7 +814,64 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is )} + {/* MiniMax Settings */} + {settings.activeProvider === 'minimax' && ( +
+
+ +
+ setSettings(prev => ({ + ...prev, + minimax: { ...prev.minimax!, apiKey: e.target.value } + }))} + placeholder="Enter your MiniMax API key" + className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all" + /> + +
+

+ Get your API key from{' '} + + MiniMax Platform + +

+
+
+ + setSettings(prev => ({ + ...prev, + minimax: { ...prev.minimax!, model: e.target.value } + }))} + placeholder="e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed" + className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm" + /> +

+ Available models: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster) +

+
+
+ )} {/* Privacy Note */}
diff --git a/gitnexus-web/src/core/lbug/lbug-adapter.ts b/gitnexus-web/src/core/lbug/lbug-adapter.ts index eed042ce2..916d4dacc 100644 --- a/gitnexus-web/src/core/lbug/lbug-adapter.ts +++ b/gitnexus-web/src/core/lbug/lbug-adapter.ts @@ -190,7 +190,7 @@ export const loadGraphToLbug = async ( for (const tableName of NODE_TABLES) { try { const countRes = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`); - const countRows = await countRes.getAll(); + const countRows = await (countRes.getAll?.() ?? countRes.getAllObjects?.() ?? countRes.getAllRows?.() ?? []); const countRow = countRows[0]; const count = countRow ? (countRow.cnt ?? countRow[0] ?? 0) : 0; totalNodes += Number(count); @@ -293,8 +293,8 @@ export const executeQuery = async (cypher: string): Promise => { }); } - // Collect all rows - const allRows = await result.getAll(); + // Collect all rows (handle API differences across LadybugDB versions) + const allRows = await (result.getAll?.() ?? result.getAllObjects?.() ?? result.getAllRows?.() ?? []); const rows: any[] = []; for (const row of allRows) { // Convert tuple to named object if we have column names and row is array @@ -331,7 +331,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> for (const tableName of NODE_TABLES) { try { const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`); - const nodeRows = await nodeResult.getAll(); + const nodeRows = await (nodeResult.getAll?.() ?? nodeResult.getAllObjects?.() ?? nodeResult.getAllRows?.() ?? []); const nodeRow = nodeRows[0]; totalNodes += Number(nodeRow?.cnt ?? nodeRow?.[0] ?? 0); } catch { @@ -343,7 +343,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> let totalEdges = 0; try { const edgeResult = await conn.query(`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`); - const edgeRows = await edgeResult.getAll(); + const edgeRows = await (edgeResult.getAll?.() ?? edgeResult.getAllObjects?.() ?? edgeResult.getAllRows?.() ?? []); const edgeRow = edgeRows[0]; totalEdges = Number(edgeRow?.cnt ?? edgeRow?.[0] ?? 0); } catch { @@ -408,7 +408,7 @@ export const executePrepared = async ( const result = await conn.execute(stmt, params); - const rows = await result.getAll(); + const rows = await (result.getAll?.() ?? result.getAllObjects?.() ?? result.getAllRows?.() ?? []); await stmt.close(); return rows; @@ -472,7 +472,7 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str for (const tableName of NODE_TABLES) { try { const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN n.id AS id LIMIT 1`); - const nodeRows = await nodeResult.getAll(); + const nodeRows = await (nodeResult.getAll?.() ?? nodeResult.getAllObjects?.() ?? nodeResult.getAllRows?.() ?? []); const nodeRow = nodeRows[0]; if (nodeRow) { testNodeId = nodeRow.id ?? nodeRow[0]; @@ -509,7 +509,7 @@ export const testArrayParams = async (): Promise<{ success: boolean; error?: str const verifyResult = await conn.query( `MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: '${testNodeId}'}) RETURN e.embedding AS emb` ); - const verifyRows = await verifyResult.getAll(); + const verifyRows = await (verifyResult.getAll?.() ?? verifyResult.getAllObjects?.() ?? verifyResult.getAllRows?.() ?? []); const verifyRow = verifyRows[0]; const storedEmb = verifyRow?.emb ?? verifyRow?.[0]; diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 6649f5742..88e07d40f 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -13,14 +13,15 @@ import { ChatAnthropic } from '@langchain/anthropic'; import { ChatOllama } from '@langchain/ollama'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { createGraphRAGTools } from './tools'; -import type { - ProviderConfig, +import type { + ProviderConfig, OpenAIConfig, - AzureOpenAIConfig, + AzureOpenAIConfig, GeminiConfig, AnthropicConfig, OllamaConfig, OpenRouterConfig, + MiniMaxConfig, AgentStreamChunk, } from './types'; import { @@ -197,7 +198,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { case 'openrouter': { const openRouterConfig = config as OpenRouterConfig; - + // Debug logging if (import.meta.env.DEV) { console.log('🌐 OpenRouter config:', { @@ -207,11 +208,11 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { baseUrl: openRouterConfig.baseUrl, }); } - + if (!openRouterConfig.apiKey || openRouterConfig.apiKey.trim() === '') { throw new Error('OpenRouter API key is required but was not provided'); } - + return new ChatOpenAI({ openAIApiKey: openRouterConfig.apiKey, apiKey: openRouterConfig.apiKey, // Fallback for some versions @@ -225,7 +226,26 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { streaming: true, }); } - + + case 'minimax': { + const minimaxConfig = config as MiniMaxConfig; + + if (!minimaxConfig.apiKey || minimaxConfig.apiKey.trim() === '') { + throw new Error('MiniMax API key is required but was not provided'); + } + + return new ChatAnthropic({ + anthropicApiKey: minimaxConfig.apiKey, + model: minimaxConfig.model, + temperature: minimaxConfig.temperature ?? 0.1, + maxTokens: minimaxConfig.maxTokens ?? 8192, + streaming: true, + clientOptions: { + baseURL: 'https://api.minimax.io/anthropic', + }, + }); + } + default: throw new Error(`Unsupported provider: ${(config as any).provider}`); } diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index 0c35b9623..f4ef5993a 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -5,9 +5,9 @@ * All API keys are stored locally - never sent to any server except the LLM provider. */ -import { - LLMSettings, - DEFAULT_LLM_SETTINGS, +import { + LLMSettings, + DEFAULT_LLM_SETTINGS, LLMProvider, OpenAIConfig, AzureOpenAIConfig, @@ -15,6 +15,7 @@ import { AnthropicConfig, OllamaConfig, OpenRouterConfig, + MiniMaxConfig, ProviderConfig, } from './types'; @@ -60,6 +61,10 @@ export const loadSettings = (): LLMSettings => { ...DEFAULT_LLM_SETTINGS.openrouter, ...parsed.openrouter, }, + minimax: { + ...DEFAULT_LLM_SETTINGS.minimax, + ...parsed.minimax, + }, }; } catch (error) { console.warn('Failed to load LLM settings:', error); @@ -89,6 +94,7 @@ export const updateProviderSettings = ( T extends 'gemini' ? Partial> : T extends 'anthropic' ? Partial> : T extends 'ollama' ? Partial> : + T extends 'minimax' ? Partial> : never > ): LLMSettings => { @@ -162,6 +168,17 @@ export const updateProviderSettings = ( saveSettings(updated); return updated; } + case 'minimax': { + const updated: LLMSettings = { + ...current, + minimax: { + ...(current.minimax ?? {}), + ...(updates as Partial>), + }, + }; + saveSettings(updated); + return updated; + } default: { // Should be unreachable due to T extends LLMProvider, but keep a safe fallback const updated: LLMSettings = { ...current }; @@ -245,7 +262,16 @@ export const getActiveProviderConfig = (): ProviderConfig | null => { temperature: settings.openrouter.temperature, maxTokens: settings.openrouter.maxTokens, } as OpenRouterConfig; - + + case 'minimax': + if (!settings.minimax?.apiKey) { + return null; + } + return { + provider: 'minimax', + ...settings.minimax, + } as MiniMaxConfig; + default: return null; } @@ -282,6 +308,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => { return 'Ollama (Local)'; case 'openrouter': return 'OpenRouter'; + case 'minimax': + return 'MiniMax'; default: return provider; } @@ -303,6 +331,8 @@ export const getAvailableModels = (provider: LLMProvider): string[] => { return ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229']; case 'ollama': return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder']; + case 'minimax': + return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed']; default: return []; } diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index 8e43673fa..d2597ec18 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -8,7 +8,7 @@ /** * Supported LLM providers */ -export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter'; +export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter' | 'minimax'; /** * Base configuration shared by all providers @@ -78,10 +78,19 @@ export interface OpenRouterConfig extends BaseProviderConfig { baseUrl?: string; // defaults to https://openrouter.ai/api/v1 } +/** + * MiniMax configuration (Anthropic-compatible API) + */ +export interface MiniMaxConfig extends BaseProviderConfig { + provider: 'minimax'; + apiKey: string; + model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed' +} + /** * Union type for all provider configurations */ -export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig; +export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig | MiniMaxConfig; /** * Stored settings (what goes to localStorage) @@ -98,6 +107,7 @@ export interface LLMSettings { anthropic?: Partial>; ollama?: Partial>; openrouter?: Partial>; + minimax?: Partial>; // Intelligent Clustering Settings intelligentClustering: boolean; @@ -148,6 +158,11 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = { baseUrl: 'https://openrouter.ai/api/v1', temperature: 0.1, }, + minimax: { + apiKey: '', + model: 'MiniMax-M2.5', + temperature: 0.1, + }, }; /** diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 233a710ee..843f967d7 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -125,6 +125,7 @@ interface AppState { runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise; runQuery: (cypher: string) => Promise; isDatabaseReady: () => Promise; + hydrateWorkerFromServer: (nodes: any[], relationships: any[], fileContents: Record) => Promise; // Embedding state embeddingStatus: EmbeddingStatus; @@ -482,6 +483,16 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } }, []); + const hydrateWorkerFromServer = useCallback(async ( + nodes: any[], + relationships: any[], + fileContents: Record + ): Promise => { + const api = apiRef.current; + if (!api) throw new Error('Worker not initialized'); + await api.hydrateFromServerData(nodes, relationships, fileContents); + }, []); + // Embedding methods const startEmbeddings = useCallback(async (forceDevice?: 'webgpu' | 'wasm'): Promise => { const api = apiRef.current; @@ -1018,15 +1029,23 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setFileContents(fileMap); setViewMode('exploring'); + setProgress(null); - if (getActiveProviderConfig()) initializeAgent(pName); + // Hydrate the worker-side DB (LadybugDB + BM25) so Query/Processes/embeddings work + hydrateWorkerFromServer(result.nodes, result.relationships, result.fileContents).then(() => { + if (getActiveProviderConfig()) initializeAgent(pName); - startEmbeddings().catch((err) => { - if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { - startEmbeddings('wasm').catch(console.warn); - } else { - console.warn('Embeddings auto-start failed:', err); - } + startEmbeddings().catch((err) => { + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); + }).catch((err) => { + console.warn('Worker hydration failed (non-fatal):', err); + // Still initialize agent even if hydration fails + if (getActiveProviderConfig()) initializeAgent(pName); }); } catch (err) { console.error('Repo switch failed:', err); @@ -1037,7 +1056,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { }); setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000); } - }, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]); + }, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, hydrateWorkerFromServer, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]); const removeCodeReference = useCallback((id: string) => { setCodeReferences(prev => { @@ -1142,6 +1161,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { runPipelineFromFiles, runQuery, isDatabaseReady, + hydrateWorkerFromServer, // Embedding state and methods embeddingStatus, embeddingProgress, diff --git a/gitnexus-web/src/workers/ingestion.worker.ts b/gitnexus-web/src/workers/ingestion.worker.ts index 0f36d8577..54974ca56 100644 --- a/gitnexus-web/src/workers/ingestion.worker.ts +++ b/gitnexus-web/src/workers/ingestion.worker.ts @@ -1,5 +1,7 @@ import * as Comlink from 'comlink'; import { runIngestionPipeline, runPipelineFromFiles } from '../core/ingestion/pipeline'; +import { createKnowledgeGraph } from '../core/graph/graph'; +import type { GraphNode, GraphRelationship } from '../core/graph/types'; import { PipelineProgress, SerializablePipelineResult, serializePipelineResult } from '../types/pipeline'; import { FileEntry } from '../services/zip'; import { @@ -207,6 +209,50 @@ const workerApi = { return serializePipelineResult(result); }, + /** + * Hydrate the worker-side database and indexes from server-loaded data. + * This is the missing step when using server/bridge mode — the main thread + * builds the React graph, but the worker's LadybugDB + BM25 stay empty. + */ + async hydrateFromServerData( + nodes: GraphNode[], + relationships: GraphRelationship[], + fileContents: Record + ): Promise { + // 1. Build a KnowledgeGraph the same way the pipeline does + const graph = createKnowledgeGraph(); + for (const node of nodes) graph.addNode(node); + for (const rel of relationships) graph.addRelationship(rel); + + // 2. Store file contents for grep/read tools + storedFileContents = new Map(Object.entries(fileContents)); + + // 3. Build BM25 keyword index + const bm25DocCount = buildBM25Index(storedFileContents); + if (import.meta.env.DEV) { + console.log(`🔍 BM25 index built (server mode): ${bm25DocCount} documents`); + } + + // 4. Set currentGraphResult so the agent context builder works + currentGraphResult = { graph, fileContents: storedFileContents }; + + // 5. Load graph into LadybugDB for Cypher queries (optional — gracefully degrades) + try { + const lbug = await getLbugAdapter(); + await lbug.loadGraphToLbug(graph, storedFileContents); + + if (import.meta.env.DEV) { + const stats = await lbug.getLbugStats(); + console.log('✅ LadybugDB hydrated (server mode):', stats); + } + } catch (err) { + // LadybugDB is optional — silently continue without it + if (import.meta.env.DEV) { + console.warn('⚠️ LadybugDB hydration failed (non-fatal):', err); + } + } + }, + /** * Execute a Cypher query against the LadybugDB database * @param cypher - The Cypher query string diff --git a/gitnexus/README.md b/gitnexus/README.md index bb90ebb04..e4fe5b623 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -2,7 +2,7 @@ **Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI. -Works with **Cursor**, **Claude Code**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool. +Works with **Cursor**, **Claude Code**, **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool. [![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus) [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/) @@ -34,6 +34,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up |--------|-----|--------|---------------------|---------| | **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** | | **Cursor** | Yes | Yes | — | MCP + Skills | +| **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | @@ -55,6 +56,12 @@ If you prefer to configure manually instead of using `gitnexus setup`: claude mcp add gitnexus -- npx -y gitnexus@latest mcp ``` +### Codex (full support — MCP + skills) + +```bash +codex mcp add gitnexus -- npx -y gitnexus@latest mcp +``` + ### Cursor / Windsurf Add to `~/.cursor/mcp.json` (global — works for all projects): diff --git a/gitnexus/package.json b/gitnexus/package.json index f80d5687d..d9a0a9298 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -20,6 +20,7 @@ "knowledge-graph", "cursor", "claude", + "codex", "ai-agent", "gitnexus", "static-analysis", diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index ca382ce6d..2ef720aa3 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -2,7 +2,7 @@ * AI Context Generator * * Creates AGENTS.md and CLAUDE.md with full inline GitNexus context. - * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Cline, etc. + * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc. * CLAUDE.md is for Claude Code which only reads that file. */ @@ -308,4 +308,3 @@ export async function generateAIContextFiles( return { files: createdFiles }; } - diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 276eb00e2..6f5164f5c 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -18,9 +18,10 @@ program program .command('setup') - .description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode') + .description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex') .action(createLazyAction(() => import('./setup.js'), 'setupCommand')); + program .command('analyze [path]') .description('Index a repository (full analysis)') diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index e8df28737..eb8149f07 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -9,12 +9,15 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { fileURLToPath } from 'url'; import { glob } from 'glob'; import { getGlobalDir } from '../storage/repo-manager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const execFileAsync = promisify(execFile); interface SetupResult { configured: string[]; @@ -239,12 +242,75 @@ async function setupOpenCode(result: SetupResult): Promise { } } +/** + * Build a TOML section for Codex MCP config (~/.codex/config.toml). + */ +function getCodexMcpTomlSection(): string { + const entry = getMcpEntry(); + const command = JSON.stringify(entry.command); + const args = `[${entry.args.map(arg => JSON.stringify(arg)).join(', ')}]`; + return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`; +} + +/** + * Append GitNexus MCP server config to Codex's config.toml if missing. + */ +async function upsertCodexConfigToml(configPath: string): Promise { + let existing = ''; + try { + existing = await fs.readFile(configPath, 'utf-8'); + } catch { + existing = ''; + } + + if (existing.includes('[mcp_servers.gitnexus]')) { + return; + } + + const section = getCodexMcpTomlSection(); + const nextContent = existing.trim().length > 0 + ? `${existing.trimEnd()}\n\n${section}` + : section; + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8'); +} + +async function setupCodex(result: SetupResult): Promise { + const codexDir = path.join(os.homedir(), '.codex'); + if (!(await dirExists(codexDir))) { + result.skipped.push('Codex (not installed)'); + return; + } + + try { + const entry = getMcpEntry(); + await execFileAsync( + 'codex', + ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], + { shell: process.platform === 'win32' } + ); + result.configured.push('Codex'); + return; + } catch { + // Fallback for environments where `codex` binary isn't on PATH. + } + + try { + const configPath = path.join(codexDir, 'config.toml'); + await upsertCodexConfigToml(configPath); + result.configured.push('Codex (MCP added to ~/.codex/config.toml)'); + } catch (err: any) { + result.errors.push(`Codex: ${err.message}`); + } +} + // ─── Skill Installation ─────────────────────────────────────────── /** * Install GitNexus skills to a target directory. * Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md - * following the Agent Skills standard (both Cursor and Claude Code). + * following the Agent Skills standard (Cursor, Claude Code, and Codex). * * Supports two source layouts: * - Flat file: skills/{name}.md → copied as SKILL.md @@ -353,6 +419,24 @@ async function installOpenCodeSkills(result: SetupResult): Promise { } } +/** + * Install global Codex skills to ~/.agents/skills/gitnexus/ + */ +async function installCodexSkills(result: SetupResult): Promise { + const codexDir = path.join(os.homedir(), '.codex'); + if (!(await dirExists(codexDir))) return; + + const skillsDir = path.join(os.homedir(), '.agents', 'skills'); + try { + const installed = await installSkillsTo(skillsDir); + if (installed.length > 0) { + result.configured.push(`Codex skills (${installed.length} skills → ~/.agents/skills/)`); + } + } catch (err: any) { + result.errors.push(`Codex skills: ${err.message}`); + } +} + // ─── Main command ────────────────────────────────────────────────── export const setupCommand = async () => { @@ -375,12 +459,14 @@ export const setupCommand = async () => { await setupCursor(result); await setupClaudeCode(result); await setupOpenCode(result); + await setupCodex(result); // Install global skills for platforms that support them await installClaudeCodeSkills(result); await installClaudeCodeHooks(result); await installCursorSkills(result); await installOpenCodeSkills(result); + await installCodexSkills(result); // Print results if (result.configured.length > 0) { diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index 60b358195..f3ddc0414 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -32,7 +32,8 @@ export type NodeLabel = | 'Delegate' | 'Annotation' | 'Constructor' - | 'Template'; + | 'Template' + | 'Section'; import { SupportedLanguages } from '../../config/supported-languages.js'; @@ -65,6 +66,8 @@ export type NodeProperties = { entryPointReason?: string, // Method signature (for MRO disambiguation) parameterCount?: number, + // Section-specific (markdown heading level, 1-6) + level?: number, returnType?: string, } diff --git a/gitnexus/src/core/ingestion/markdown-processor.ts b/gitnexus/src/core/ingestion/markdown-processor.ts new file mode 100644 index 000000000..d894cee43 --- /dev/null +++ b/gitnexus/src/core/ingestion/markdown-processor.ts @@ -0,0 +1,157 @@ +/** + * Markdown Processor + * + * Extracts structure from .md files using regex (no tree-sitter dependency). + * Creates Section nodes for headings with hierarchy, and IMPORTS edges for + * cross-file links. + */ + +import path from 'node:path'; +import { generateId } from '../../lib/utils.js'; +import { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.js'; + +const HEADING_RE = /^(#{1,6})\s+(.+)$/; +const LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g; +const MD_EXTENSIONS = new Set(['.md', '.mdx']); + +interface MdFile { + path: string; + content: string; +} + +export const processMarkdown = ( + graph: KnowledgeGraph, + files: MdFile[], + allPathSet: Set, +): { sections: number; links: number } => { + let totalSections = 0; + let totalLinks = 0; + + for (const file of files) { + const ext = path.extname(file.path).toLowerCase(); + if (!MD_EXTENSIONS.has(ext)) continue; + + const fileNodeId = generateId('File', file.path); + // Skip if file node doesn't exist (shouldn't happen, structure-processor creates it) + if (!graph.getNode(fileNodeId)) continue; + + const lines = file.content.split('\n'); + + // --- Extract headings and build hierarchy --- + // First pass: collect all heading positions so we can compute endLine spans + const headings: { level: number; heading: string; lineNum: number }[] = []; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(HEADING_RE); + if (!match) continue; + + headings.push({ + level: match[1].length, + heading: match[2].trim(), + lineNum: i + 1, // 1-indexed + }); + } + + // Second pass: create nodes with proper endLine spans + const sectionStack: { level: number; id: string }[] = []; + + for (let h = 0; h < headings.length; h++) { + const { level, heading, lineNum } = headings[h]; + + // endLine = line before next heading at same or higher level, or EOF + let endLine = lines.length; + for (let j = h + 1; j < headings.length; j++) { + if (headings[j].level <= level) { + endLine = headings[j].lineNum - 1; + break; + } + } + + const sectionId = generateId('Section', `${file.path}:L${lineNum}:${heading}`); + + const node: GraphNode = { + id: sectionId, + label: 'Section', + properties: { + name: heading, + filePath: file.path, + startLine: lineNum, + endLine, + level, + description: `h${level}`, + }, + }; + graph.addNode(node); + totalSections++; + + // Find parent: pop stack until we find a level strictly less than current + while (sectionStack.length > 0 && sectionStack[sectionStack.length - 1].level >= level) { + sectionStack.pop(); + } + + const parentId = sectionStack.length > 0 + ? sectionStack[sectionStack.length - 1].id + : fileNodeId; + + graph.addRelationship({ + id: generateId('CONTAINS', `${parentId}->${sectionId}`), + type: 'CONTAINS', + sourceId: parentId, + targetId: sectionId, + confidence: 1.0, + reason: 'markdown-heading', + }); + + sectionStack.push({ level, id: sectionId }); + } + + // --- Extract links to other files in the repo --- + const fileDir = path.dirname(file.path); + const seenLinks = new Set(); + let linkMatch: RegExpExecArray | null; + LINK_RE.lastIndex = 0; + + while ((linkMatch = LINK_RE.exec(file.content)) !== null) { + const href = linkMatch[2]; + + // Skip external URLs, anchors, and mailto + if (href.startsWith('http://') || href.startsWith('https://') || + href.startsWith('#') || href.startsWith('mailto:')) { + continue; + } + + // Strip anchor fragments from local links + const cleanHref = href.split('#')[0]; + if (!cleanHref) continue; + + // Resolve relative to the file's directory, then normalize + const resolved = path.posix.normalize(path.posix.join(fileDir, cleanHref)); + + if (allPathSet.has(resolved)) { + const targetFileId = generateId('File', resolved); + + // Skip if target file node doesn't exist + if (!graph.getNode(targetFileId)) continue; + + // Dedup: skip if we've already linked this file pair + const linkKey = `${fileNodeId}->${targetFileId}`; + if (seenLinks.has(linkKey)) continue; + seenLinks.add(linkKey); + + const relId = generateId('IMPORTS', linkKey); + + graph.addRelationship({ + id: relId, + type: 'IMPORTS', + sourceId: fileNodeId, + targetId: targetFileId, + confidence: 0.8, + reason: 'markdown-link', + }); + totalLinks++; + } + } + } + + return { sections: totalSections, links: totalLinks }; +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index a595a2d64..8b01e260e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1,5 +1,6 @@ import { createKnowledgeGraph } from '../graph/graph.js'; import { processStructure } from './structure-processor.js'; +import { processMarkdown } from './markdown-processor.js'; import { processParsing } from './parsing-processor.js'; import { processImports, @@ -298,6 +299,21 @@ export const runPipelineFromRepo = async ( stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount }, }); + + // ── Phase 2.5: Markdown processing (headings + cross-links) ──────── + const mdScanned = scannedFiles.filter(f => f.path.endsWith('.md') || f.path.endsWith('.mdx')); + if (mdScanned.length > 0) { + const mdContents = await readFileContents(repoPath, mdScanned.map(f => f.path)); + const mdFiles = mdScanned + .filter(f => mdContents.has(f.path)) + .map(f => ({ path: f.path, content: mdContents.get(f.path)! })); + const allPathSet = new Set(allPaths); + const mdResult = processMarkdown(graph, mdFiles, allPathSet); + if (isDev) { + console.log(` Markdown: ${mdResult.sections} sections, ${mdResult.links} cross-links from ${mdFiles.length} files`); + } + } + // ── Phase 3+4: Chunked read + parse ──────────────────────────────── // Group parseable files into byte-budget chunks so only ~20MB of source // is in memory at a time. Each chunk is: read → parse → extract → free. diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 9c99eb04b..e8f8a2fc8 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -238,6 +238,9 @@ export const streamAllCSVsToDisk = async ( const communityWriter = new BufferedCSVWriter(path.join(csvDir, 'community.csv'), 'id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount'); const processWriter = new BufferedCSVWriter(path.join(csvDir, 'process.csv'), 'id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId'); + // Section nodes have an extra 'level' column + const sectionWriter = new BufferedCSVWriter(path.join(csvDir, 'section.csv'), 'id,name,filePath,startLine,endLine,level,content,description'); + // Multi-language node types share the same CSV shape (no isExported column) const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description'; const MULTI_LANG_TYPES = ['Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl', @@ -324,6 +327,20 @@ export const streamAllCSVsToDisk = async ( ].join(',')); break; } + case 'Section': { + const content = await extractContent(node, contentCache); + await sectionWriter.addRow([ + escapeCSVField(node.id), + escapeCSVField(node.properties.name || ''), + escapeCSVField(node.properties.filePath || ''), + escapeCSVNumber(node.properties.startLine, -1), + escapeCSVNumber(node.properties.endLine, -1), + escapeCSVNumber((node.properties as any).level, 1), + escapeCSVField(content), + escapeCSVField((node.properties as any).description || ''), + ].join(',')); + break; + } default: { // Code element nodes (Function, Class, Interface, CodeElement) const writer = codeWriterMap[node.label]; @@ -361,7 +378,7 @@ export const streamAllCSVsToDisk = async ( } // Finish all node writers - const allWriters = [fileWriter, folderWriter, functionWriter, classWriter, interfaceWriter, methodWriter, codeElemWriter, communityWriter, processWriter, ...multiLangWriters.values()]; + const allWriters = [fileWriter, folderWriter, functionWriter, classWriter, interfaceWriter, methodWriter, codeElemWriter, communityWriter, processWriter, sectionWriter, ...multiLangWriters.values()]; await Promise.all(allWriters.map(w => w.finish())); // --- Stream relationship CSV --- @@ -387,6 +404,7 @@ export const streamAllCSVsToDisk = async ( ['Interface', interfaceWriter], ['Method', methodWriter], ['CodeElement', codeElemWriter], ['Community', communityWriter], ['Process', processWriter], + ['Section' as NodeTableName, sectionWriter], ...Array.from(multiLangWriters.entries()).map(([name, w]) => [name as NodeTableName, w] as [NodeTableName, BufferedCSVWriter]), ]; for (const [name, writer] of tableMap) { diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index bd4061797..ff8345d06 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -336,6 +336,9 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => { if (table === 'Process') { return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Section') { + return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; + } if (table === 'Method') { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`; } @@ -380,6 +383,9 @@ export const insertNodeToLbug = async ( query = `CREATE (n:File {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, content: ${escapeValue(properties.content || '')}})`; } else if (label === 'Folder') { query = `CREATE (n:Folder {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}})`; + } else if (label === 'Section') { + const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : ''; + query = `CREATE (n:Section {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, level: ${properties.level || 1}, content: ${escapeValue(properties.content || '')}${descPart}})`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : ''; query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`; @@ -451,6 +457,9 @@ export const batchInsertNodesToLbug = async ( query = `MERGE (n:File {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.content = ${escapeValue(properties.content || '')}`; } else if (label === 'Folder') { query = `MERGE (n:Folder {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}`; + } else if (label === 'Section') { + const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : ''; + query = `MERGE (n:Section {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.level = ${properties.level || 1}, n.content = ${escapeValue(properties.content || '')}${descPart}`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : ''; query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`; diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index ef1fbad50..a47aa9674 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -13,7 +13,7 @@ // NODE TABLE NAMES // ============================================================================ export const NODE_TABLES = [ - 'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process', + 'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process', 'Section', // Multi-language support 'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module' @@ -192,6 +192,19 @@ export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation'); export const CONSTRUCTOR_SCHEMA = CODE_ELEMENT_BASE('Constructor'); export const TEMPLATE_SCHEMA = CODE_ELEMENT_BASE('Template'); export const MODULE_SCHEMA = CODE_ELEMENT_BASE('Module'); +// Markdown heading sections +export const SECTION_SCHEMA = ` +CREATE NODE TABLE Section ( + id STRING, + name STRING, + filePath STRING, + startLine INT64, + endLine INT64, + level INT64, + content STRING, + description STRING, + PRIMARY KEY (id) +)`; // ============================================================================ // RELATION TABLE SCHEMA @@ -225,6 +238,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM File TO \`Constructor\`, FROM File TO \`Template\`, FROM File TO \`Module\`, + FROM File TO Section, FROM Folder TO Folder, FROM Folder TO File, FROM Function TO Function, @@ -289,6 +303,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Template\` TO Interface, FROM \`Template\` TO \`Constructor\`, FROM \`Module\` TO \`Module\`, + FROM Section TO Section, + FROM Section TO File, FROM CodeElement TO Community, FROM Interface TO Community, FROM Interface TO Function, @@ -447,6 +463,8 @@ export const NODE_SCHEMA_QUERIES = [ CONSTRUCTOR_SCHEMA, TEMPLATE_SCHEMA, MODULE_SCHEMA, + // Markdown support + SECTION_SCHEMA, ]; export const REL_SCHEMA_QUERIES = [ diff --git a/gitnexus/src/mcp/core/lbug-adapter.ts b/gitnexus/src/mcp/core/lbug-adapter.ts index cf9bb1ad6..0de368d73 100644 --- a/gitnexus/src/mcp/core/lbug-adapter.ts +++ b/gitnexus/src/mcp/core/lbug-adapter.ts @@ -148,6 +148,8 @@ function closeOne(repoId: string): void { * Create a new Connection from a repo's Database. * Silences stdout to prevent native module output from corrupting MCP stdio. */ +let activeQueryCount = 0; + function silenceStdout(): void { if (stdoutSilenceCount++ === 0) { process.stdout.write = (() => true) as any; @@ -163,8 +165,10 @@ function restoreStdout(): void { // Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash // inside createConnection before restoreStdout runs). +// Exempts active queries and pre-warm — these legitimately hold silence for +// longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s). setInterval(() => { - if (stdoutSilenceCount > 0 && !preWarmActive) { + if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) { stdoutSilenceCount = 0; process.stdout.write = realStdoutWrite; } @@ -458,12 +462,16 @@ export const executeQuery = async (repoId: string, cypher: string): Promise = new Map(); private contextCache: Map = new Map(); private initializedRepos: Set = new Set(); + private reinitPromises: Map> = new Map(); + private lastStalenessCheck: Map = new Map(); // ─── Initialization ────────────────────────────────────────────── @@ -246,12 +248,51 @@ export class LocalBackend { // ─── Lazy LadybugDB Init ──────────────────────────────────────────── private async ensureInitialized(repoId: string): Promise { - // Always check the actual pool — the idle timer may have evicted the connection - if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) return; + // If a reinit is already in progress for this repo, wait for it + const pending = this.reinitPromises.get(repoId); + if (pending) return pending; const handle = this.repos.get(repoId); if (!handle) throw new Error(`Unknown repo: ${repoId}`); + // Check if the index was rebuilt since we opened the connection (#297). + // Throttle staleness checks to at most once per 5 seconds per repo to + // avoid an fs.readFile round-trip on every tool invocation. + if (this.initializedRepos.has(repoId) && isLbugReady(repoId)) { + const now = Date.now(); + const lastCheck = this.lastStalenessCheck.get(repoId) ?? 0; + if (now - lastCheck < 5000) return; // Checked recently — skip + + this.lastStalenessCheck.set(repoId, now); + try { + const metaPath = path.join(handle.storagePath, 'meta.json'); + const metaRaw = await fs.readFile(metaPath, 'utf-8'); + const meta = JSON.parse(metaRaw); + if (meta.indexedAt && meta.indexedAt !== handle.indexedAt) { + // Index was rebuilt — close stale connection and re-init. + // Wrap in reinitPromises to prevent TOCTOU race where concurrent + // callers both detect staleness and double-close the pool. + const reinit = (async () => { + try { + await closeLbug(repoId); + this.initializedRepos.delete(repoId); + handle.indexedAt = meta.indexedAt; + await initLbug(repoId, handle.lbugPath); + this.initializedRepos.add(repoId); + } finally { + this.reinitPromises.delete(repoId); + } + })(); + this.reinitPromises.set(repoId, reinit); + return reinit; + } else { + return; // Pool is current + } + } catch { + return; // Can't read meta — assume pool is fine + } + } + try { await initLbug(repoId, handle.lbugPath); this.initializedRepos.add(repoId); @@ -1438,31 +1479,51 @@ export class LocalBackend { let affectedModules: any[] = []; if (impacted.length > 0) { - const allIds = impacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', '); - const d1Ids = (grouped[1] || []).map((i: any) => `'${i.id.replace(/'/g, "''")}'`).join(', '); + // Cap IN-clause to 100 IDs to prevent oversized queries that crash + // the native DB engine on arm64 macOS (#292) + const cappedImpacted = impacted.slice(0, 100); + const allIds = cappedImpacted.map(i => `'${String(i.id ?? '').replace(/'/g, "''")}'`).join(', '); + const d1Items = (grouped[1] || []).slice(0, 100); + const d1Ids = d1Items.map((i: any) => `'${String(i.id ?? '').replace(/'/g, "''")}'`).join(', '); - // Affected processes: which execution flows are broken and at which step - const [processRows, moduleRows, directModuleRows] = await Promise.all([ - executeQuery(repo.id, ` - MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) - WHERE s.id IN [${allIds}] - RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount - ORDER BY hits DESC - LIMIT 20 - `).catch(() => []), - executeQuery(repo.id, ` - MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE s.id IN [${allIds}] - RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits - ORDER BY hits DESC - LIMIT 20 - `).catch(() => []), - d1Ids ? executeQuery(repo.id, ` - MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) - WHERE s.id IN [${d1Ids}] - RETURN DISTINCT c.heuristicLabel AS name - `).catch(() => []) : Promise.resolve([]), - ]); + // Enrichment queries: sequential on arm64 macOS to avoid SIGSEGV from + // concurrent native DB access (#285, #290, #292); parallel elsewhere + // to preserve performance on unaffected platforms. + const isArm64Mac = process.platform === 'darwin' && process.arch === 'arm64'; + + const processQuery = executeQuery(repo.id, ` + MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + WHERE s.id IN [${allIds}] + RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount + ORDER BY hits DESC + LIMIT 20 + `).catch(() => []); + const moduleQuery = () => executeQuery(repo.id, ` + MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) + WHERE s.id IN [${allIds}] + RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits + ORDER BY hits DESC + LIMIT 20 + `).catch(() => []); + const directModuleQuery = () => d1Ids + ? executeQuery(repo.id, ` + MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) + WHERE s.id IN [${d1Ids}] + RETURN DISTINCT c.heuristicLabel AS name + `).catch(() => []) + : Promise.resolve([]); + + let processRows: any[], moduleRows: any[], directModuleRows: any[]; + if (isArm64Mac) { + // Sequential: avoid concurrent native DB access + processRows = await processQuery; + moduleRows = await moduleQuery(); + directModuleRows = await directModuleQuery(); + } else { + // Parallel: safe on non-arm64 platforms + processRows = await processQuery; + [moduleRows, directModuleRows] = await Promise.all([moduleQuery(), directModuleQuery()]); + } affectedProcesses = processRows.map((r: any) => ({ name: r.name || r[0], diff --git a/gitnexus/test/integration/setup-skills.test.ts b/gitnexus/test/integration/setup-skills.test.ts index f6f35f76f..f6e3801e5 100644 --- a/gitnexus/test/integration/setup-skills.test.ts +++ b/gitnexus/test/integration/setup-skills.test.ts @@ -9,6 +9,7 @@ describe('setupCommand skills integration', () => { let tempHome: string; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; + const originalPath = process.env.PATH; const testId = `${Date.now()}-${process.pid}`; const flatSkillName = `test-flat-skill-${testId}`; const dirSkillName = `test-dir-skill-${testId}`; @@ -47,6 +48,7 @@ describe('setupCommand skills integration', () => { await fs.rm(path.join(packageSkillsRoot, dirSkillName), { recursive: true, force: true }); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + process.env.PATH = originalPath; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -85,4 +87,40 @@ describe('setupCommand skills integration', () => { ); expect(nestedInstalled).toContain('Directory Nested File'); }); + + it('falls back to Codex config.toml and installs skills into ~/.agents/skills when codex CLI is unavailable', async () => { + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + process.env.PATH = ''; + + await setupCommand(); + + const codexConfig = await fs.readFile( + path.join(tempHome, '.codex', 'config.toml'), + 'utf-8', + ); + expect(codexConfig).toContain('[mcp_servers.gitnexus]'); + expect(codexConfig).toContain('gitnexus@latest'); + + const codexSkill = await fs.readFile( + path.join(tempHome, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(codexSkill).toContain('GitNexus CLI Commands'); + }); + + it('does not duplicate the Codex MCP section on repeated fallback setup runs', async () => { + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + process.env.PATH = ''; + + await setupCommand(); + await setupCommand(); + + const codexConfig = await fs.readFile( + path.join(tempHome, '.codex', 'config.toml'), + 'utf-8', + ); + const sectionMatches = codexConfig.match(/\[mcp_servers\.gitnexus\]/g) ?? []; + + expect(sectionMatches).toHaveLength(1); + }); }); diff --git a/gitnexus/test/integration/skills-e2e.test.ts b/gitnexus/test/integration/skills-e2e.test.ts index 31f836179..58a98e984 100644 --- a/gitnexus/test/integration/skills-e2e.test.ts +++ b/gitnexus/test/integration/skills-e2e.test.ts @@ -2389,8 +2389,16 @@ export function createEntry(level: string, msg: string) { /* CI timeout tolerance */ if (result1.status === null || result2.status === null) return; - expect(result1.status).toBe(0); - expect(result2.status).toBe(0); + expect(result1.status, [ + `first analyze --skills exited with code ${result1.status}`, + `stdout: ${result1.stdout?.slice(0, 500)}`, + `stderr: ${result1.stderr?.slice(0, 500)}`, + ].join('\n')).toBe(0); + expect(result2.status, [ + `second analyze --skills exited with code ${result2.status}`, + `stdout: ${result2.stdout?.slice(0, 500)}`, + `stderr: ${result2.stderr?.slice(0, 500)}`, + ].join('\n')).toBe(0); const generatedDir = path.join(tmpDir, '.claude', 'skills', 'generated'); expect(fs.existsSync(generatedDir)).toBe(true); diff --git a/gitnexus/test/integration/staleness-and-stability.test.ts b/gitnexus/test/integration/staleness-and-stability.test.ts new file mode 100644 index 000000000..023ec5969 --- /dev/null +++ b/gitnexus/test/integration/staleness-and-stability.test.ts @@ -0,0 +1,226 @@ +/** + * E2E Tests: Stale Data Detection + Sequential Enrichment Stability + * + * Validates the fixes in PR #396: + * 1. Sequential enrichment: impact() enrichment queries run without + * SIGSEGV on arm64 macOS (sequential on arm64, parallel elsewhere) + * 2. Consecutive tool stability: MCP server stays alive after 10+ + * consecutive tool calls (no stdout corruption) + * 3. Watchdog guard: activeQueryCount prevents premature stdout restore + * 4. Stale data detection: ensureInitialized() detects meta.json changes + * + * All tests share one withTestLbugDB lifecycle to avoid cross-block + * DB closure issues (LadybugDB's shared global DB in a single fork). + * + * Issues: #285, #290, #292, #297 + */ +import { describe, it, expect, afterAll } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import { + initLbug, + executeQuery, + closeLbug, +} from '../../src/mcp/core/lbug-adapter.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; +import { LOCAL_BACKEND_SEED_DATA, LOCAL_BACKEND_FTS_INDEXES } from '../fixtures/local-backend-seed.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { vi } from 'vitest'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), +})); + +withTestLbugDB('staleness-and-stability', (handle) => { + let backend: LocalBackend; + let storagePath: string; + + // ─── Setup ───────────────────────────────────────────────────────── + describe('setup', () => { + it('initialize backend', async () => { + const ext = handle as typeof handle & { _backend?: LocalBackend }; + if (!ext._backend) throw new Error('LocalBackend not initialized'); + backend = ext._backend; + storagePath = handle.tmpHandle.dbPath; + }); + }); + + // ─── Block 1: Sequential enrichment queries (#285, #290, #292) ───── + describe('impact enrichment queries run without crashes', () => { + it('impact with enrichment completes without SIGSEGV', async () => { + const result = await backend.callTool('impact', { + target: 'validate', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.impactedCount).toBeGreaterThanOrEqual(1); + expect(result).toHaveProperty('affected_processes'); + expect(result).toHaveProperty('affected_modules'); + }); + + it('impact with large maxDepth completes without crash', async () => { + const result = await backend.callTool('impact', { + target: 'login', + direction: 'downstream', + maxDepth: 5, + }); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + }); + + // ─── Block 2: Consecutive tool call stability ────────────────────── + describe('MCP server stays alive after 10+ consecutive tool calls', () => { + it('10 consecutive cypher calls complete without stdout corruption', async () => { + for (let i = 0; i < 10; i++) { + const result = await backend.callTool('cypher', { + query: `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`, + }); + expect(result).toHaveProperty('row_count'); + expect(result.row_count).toBeGreaterThanOrEqual(1); + } + }); + + it('mixed tool calls: context → impact → query → cypher cycle', async () => { + for (let i = 0; i < 3; i++) { + const ctx = await backend.callTool('context', { name: 'login' }); + expect(ctx.status).toBe('found'); + + const imp = await backend.callTool('impact', { + target: 'validate', + direction: 'upstream', + }); + expect(imp).not.toHaveProperty('error'); + + const qry = await backend.callTool('query', { query: 'login' }); + expect(qry).not.toHaveProperty('error'); + + const cyp = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', + }); + expect(cyp).toHaveProperty('row_count'); + } + }); + + it('stdout.write is still a function after all calls', () => { + expect(typeof process.stdout.write).toBe('function'); + }); + }); + + // ─── Block 3: Watchdog / activeQueryCount ────────────────────────── + describe('watchdog does not restore stdout during active queries', () => { + const REPO = 'watchdog-test'; + let poolInited = false; + + const ensurePool = async () => { + if (!poolInited) { + await initLbug(REPO, handle.dbPath); + poolInited = true; + } + }; + + afterAll(async () => { + try { await closeLbug(REPO); } catch { /* best-effort */ } + }); + + it('parallel queries complete and stdout is restored', async () => { + await ensurePool(); + const queries = Array.from({ length: 4 }, (_, i) => + executeQuery(REPO, `MATCH (n:Function) RETURN n.name AS name LIMIT ${i + 1}`) + ); + const results = await Promise.all(queries); + expect(results).toHaveLength(4); + for (const r of results) { + expect(r.length).toBeGreaterThanOrEqual(1); + } + }); + + it('sequential queries still work', async () => { + await ensurePool(); + for (let i = 0; i < 5; i++) { + const rows = await executeQuery(REPO, 'MATCH (n:Function) RETURN n.name'); + expect(rows.length).toBeGreaterThanOrEqual(1); + } + }); + }); + + // ─── Block 4: Stale data detection (#297) ────────────────────────── + // LAST: triggers closeLbug internally which may affect shared state + describe('stale data detection via meta.json', () => { + it('initial query works', async () => { + const result = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name', + }); + expect(result).toHaveProperty('row_count'); + expect(result.row_count).toBeGreaterThanOrEqual(3); + }); + + it('detects stale index when meta.json indexedAt changes', async () => { + const metaPath = path.join(storagePath, 'meta.json'); + await fs.writeFile(metaPath, JSON.stringify({ + indexedAt: new Date(Date.now() + 60000).toISOString(), + lastCommit: 'new-commit-hash', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + })); + + // Next call triggers re-init. May fail but must NOT crash. + try { + const result = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', + }); + expect(result).toBeDefined(); + } catch (err: any) { + expect(err.message).not.toMatch(/SIGSEGV/i); + } + }); + + it('throttle: no re-read within 5s window', async () => { + const metaPath = path.join(storagePath, 'meta.json'); + await fs.writeFile(metaPath, JSON.stringify({ + indexedAt: new Date(Date.now() + 120000).toISOString(), + lastCommit: 'another-commit', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + })); + + try { + const result = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', + }); + expect(result).toBeDefined(); + } catch { + // No crash = success + } + }); + }); + +}, { + seed: LOCAL_BACKEND_SEED_DATA, + ftsIndexes: LOCAL_BACKEND_FTS_INDEXES, + poolAdapter: true, + afterSetup: async (handle) => { + // Write initial meta.json for staleness tests + const metaPath = path.join(handle.tmpHandle.dbPath, 'meta.json'); + const initialMeta = { + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }; + await fs.writeFile(metaPath, JSON.stringify(initialMeta)); + + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: initialMeta.indexedAt, + lastCommit: 'abc123', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, +}); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 87e369652..75235f0e4 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -40,7 +40,7 @@ describe('LadybugDB Schema', () => { it('has expected total count', () => { // 9 core + 18 multi-language = 27 - expect(NODE_TABLES).toHaveLength(27); + expect(NODE_TABLES).toHaveLength(28); }); }); @@ -164,7 +164,7 @@ describe('LadybugDB Schema', () => { describe('schema query ordering', () => { it('NODE_SCHEMA_QUERIES has correct count', () => { - expect(NODE_SCHEMA_QUERIES).toHaveLength(27); + expect(NODE_SCHEMA_QUERIES).toHaveLength(28); }); it('REL_SCHEMA_QUERIES has one relation table', () => { @@ -173,7 +173,7 @@ describe('LadybugDB Schema', () => { it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => { // 27 node + 1 rel + 1 embedding = 29 - expect(SCHEMA_QUERIES).toHaveLength(29); + expect(SCHEMA_QUERIES).toHaveLength(30); }); it('node schemas come before relation schemas in SCHEMA_QUERIES', () => { diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts new file mode 100644 index 000000000..9a6650861 --- /dev/null +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +const execFileMock = vi.fn((...args: any[]) => { + const callback = args.at(-1); + if (typeof callback === 'function') { + callback(null, '', ''); + } +}); + +vi.mock('child_process', () => ({ + execFile: execFileMock, +})); + +describe('setupCommand codex execution', () => { + let tempHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + let platformDescriptor: PropertyDescriptor | undefined; + + const setPlatform = (value: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + }); + }; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-setup-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + setPlatform('win32'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + it('invokes codex mcp add with shell enabled on Windows', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'], + { shell: true }, + expect.any(Function), + ); + }); + + it('invokes codex mcp add without shell on non-Windows and does not write fallback config', async () => { + setPlatform('darwin'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'npx', '-y', 'gitnexus@latest', 'mcp'], + { shell: false }, + expect.any(Function), + ); + + await expect( + fs.access(path.join(tempHome, '.codex', 'config.toml')), + ).rejects.toThrow(); + }); + + it('skips Codex setup entirely when ~/.codex is missing', async () => { + await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).not.toHaveBeenCalled(); + await expect( + fs.access(path.join(tempHome, '.agents', 'skills')), + ).rejects.toThrow(); + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 6a2956856..591f06766 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -59,6 +59,7 @@ export default defineConfig({ 'test/integration/search-core.test.ts', 'test/integration/search-pool.test.ts', 'test/integration/augmentation.test.ts', + 'test/integration/staleness-and-stability.test.ts', ], fileParallelism: false, sequence: { groupOrder: 1 }, @@ -79,6 +80,7 @@ export default defineConfig({ 'test/integration/search-core.test.ts', 'test/integration/search-pool.test.ts', 'test/integration/augmentation.test.ts', + 'test/integration/staleness-and-stability.test.ts', ], }, },