mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-13 08:51:28 +00:00
fixed blast radius analyses, method calls relations failing solved, mcp simplified ( need to publish into npm )
This commit is contained in:
parent
055f13f9c8
commit
87fedd17e7
13 changed files with 148 additions and 931 deletions
|
|
@ -16,10 +16,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"commander": "^12.0.0",
|
||||
"ws": "^8.16.0",
|
||||
"chalk": "^5.3.0",
|
||||
"ora": "^8.0.0"
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
/**
|
||||
* Daemon Client
|
||||
*
|
||||
* WebSocket client that connects to the GitNexus daemon.
|
||||
* Used by `serve` command to route tool calls through the daemon to the browser.
|
||||
* Also receives codebase context for MCP resource exposure.
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export interface BridgeMessage {
|
||||
id: string;
|
||||
method?: string;
|
||||
params?: any;
|
||||
result?: any;
|
||||
error?: { message: string };
|
||||
type?: string;
|
||||
context?: CodebaseContext;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codebase context from browser
|
||||
*/
|
||||
export interface CodebaseContext {
|
||||
projectName: string;
|
||||
stats: {
|
||||
fileCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
interfaceCount: number;
|
||||
methodCount: number;
|
||||
};
|
||||
hotspots: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
filePath: string;
|
||||
connections: number;
|
||||
}>;
|
||||
folderTree: string;
|
||||
}
|
||||
|
||||
type RequestResolver = {
|
||||
resolve: (result: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
export class DaemonClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pendingRequests: Map<string, RequestResolver> = new Map();
|
||||
private requestId = 0;
|
||||
private _context: CodebaseContext | null = null;
|
||||
private contextListeners: Set<(context: CodebaseContext | null) => void> = new Set();
|
||||
private agentName: string;
|
||||
|
||||
constructor(private port: number = 54319, agentName?: string) {
|
||||
// Detect agent from environment or use provided name
|
||||
this.agentName = agentName || process.env.GITNEXUS_AGENT || this.detectAgent();
|
||||
}
|
||||
|
||||
private detectAgent(): string {
|
||||
// Try to detect agent from environment clues
|
||||
if (process.env.CURSOR_SESSION_ID) return 'Cursor';
|
||||
if (process.env.CLAUDE_CODE) return 'Claude Code';
|
||||
if (process.env.WINDSURF_SESSION) return 'Windsurf';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the daemon
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Connect to /mcp path so daemon knows this is an MCP client
|
||||
this.ws = new WebSocket(`ws://localhost:${this.port}/mcp`);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const msg: BridgeMessage = JSON.parse(data.toString());
|
||||
this.handleMessage(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this.ws = null;
|
||||
this._context = null;
|
||||
// Reject all pending requests
|
||||
for (const { reject } of this.pendingRequests.values()) {
|
||||
reject(new Error('Connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(msg: BridgeMessage) {
|
||||
// Handle context update from daemon
|
||||
if (msg.type === 'context_update' && msg.context) {
|
||||
this._context = msg.context;
|
||||
this.notifyContextListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Response from daemon (originally from browser)
|
||||
if (msg.id && this.pendingRequests.has(msg.id)) {
|
||||
const { resolve, reject } = this.pendingRequests.get(msg.id)!;
|
||||
this.pendingRequests.delete(msg.id);
|
||||
|
||||
if (msg.error) {
|
||||
reject(new Error(msg.error.message));
|
||||
} else {
|
||||
resolve(msg.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current codebase context
|
||||
*/
|
||||
get context(): CodebaseContext | null {
|
||||
return this._context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for context changes
|
||||
*/
|
||||
onContextChange(listener: (context: CodebaseContext | null) => void) {
|
||||
this.contextListeners.add(listener);
|
||||
return () => this.contextListeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyContextListeners() {
|
||||
this.contextListeners.forEach(listener => listener(this._context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to daemon
|
||||
*/
|
||||
get isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool (routed through daemon to browser)
|
||||
*/
|
||||
async callTool(method: string, params: any): Promise<any> {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to daemon');
|
||||
}
|
||||
|
||||
const id = `req_${++this.requestId}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
|
||||
const msg: BridgeMessage = { id, method, params, agentName: this.agentName };
|
||||
this.ws!.send(JSON.stringify(msg));
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error('Request timeout'));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from daemon
|
||||
*/
|
||||
disconnect() {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,29 @@ export interface BridgeMessage {
|
|||
params?: any;
|
||||
result?: any;
|
||||
error?: { message: string };
|
||||
type?: 'context' | string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codebase context sent from the GitNexus browser app
|
||||
*/
|
||||
export interface CodebaseContext {
|
||||
projectName: string;
|
||||
stats: {
|
||||
fileCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
interfaceCount: number;
|
||||
methodCount: number;
|
||||
};
|
||||
hotspots: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
filePath: string;
|
||||
connections: number;
|
||||
}>;
|
||||
folderTree: string;
|
||||
}
|
||||
|
||||
type RequestResolver = {
|
||||
|
|
@ -42,8 +65,21 @@ export class WebSocketBridge {
|
|||
private pendingRequests: Map<string, RequestResolver> = new Map();
|
||||
private requestId = 0;
|
||||
private started = false;
|
||||
private _context: CodebaseContext | null = null;
|
||||
private contextListeners: Set<(context: CodebaseContext | null) => void> = new Set();
|
||||
private agentName: string;
|
||||
|
||||
constructor(private port: number = 54319) {}
|
||||
constructor(private port: number = 54319, agentName?: string) {
|
||||
this.agentName = agentName || process.env.GITNEXUS_AGENT || this.detectAgent();
|
||||
}
|
||||
|
||||
private detectAgent(): string {
|
||||
// Try to detect agent from environment clues
|
||||
if (process.env.CURSOR_SESSION_ID) return 'Cursor';
|
||||
if (process.env.CLAUDE_CODE) return 'Claude Code';
|
||||
if (process.env.WINDSURF_SESSION) return 'Windsurf';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the WebSocket server (handles port-in-use gracefully)
|
||||
|
|
@ -67,6 +103,9 @@ export class WebSocketBridge {
|
|||
this.client.close();
|
||||
}
|
||||
this.client = ws;
|
||||
// Clear context until browser sends an update
|
||||
this._context = null;
|
||||
this.notifyContextListeners();
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
|
|
@ -80,6 +119,8 @@ export class WebSocketBridge {
|
|||
ws.on('close', () => {
|
||||
if (this.client === ws) {
|
||||
this.client = null;
|
||||
this._context = null;
|
||||
this.notifyContextListeners();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -101,6 +142,13 @@ export class WebSocketBridge {
|
|||
}
|
||||
|
||||
private handleMessage(msg: BridgeMessage) {
|
||||
// Browser can proactively send codebase context
|
||||
if (msg.type === 'context' && msg.params) {
|
||||
this._context = msg.params as CodebaseContext;
|
||||
this.notifyContextListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a response to a pending request
|
||||
if (msg.id && this.pendingRequests.has(msg.id)) {
|
||||
const { resolve, reject } = this.pendingRequests.get(msg.id)!;
|
||||
|
|
@ -121,6 +169,25 @@ export class WebSocketBridge {
|
|||
return this.client !== null && this.client.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest context received from browser (if any)
|
||||
*/
|
||||
get context(): CodebaseContext | null {
|
||||
return this._context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for context changes
|
||||
*/
|
||||
onContextChange(listener: (context: CodebaseContext | null) => void) {
|
||||
this.contextListeners.add(listener);
|
||||
return () => this.contextListeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyContextListeners() {
|
||||
this.contextListeners.forEach((listener) => listener(this._context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server started successfully
|
||||
*/
|
||||
|
|
@ -141,7 +208,7 @@ export class WebSocketBridge {
|
|||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
|
||||
const msg: BridgeMessage = { id, method, params };
|
||||
const msg: BridgeMessage = { id, method, params, agentName: this.agentName };
|
||||
this.client!.send(JSON.stringify(msg));
|
||||
|
||||
// Timeout after 30 seconds
|
||||
|
|
@ -160,4 +227,11 @@ export class WebSocketBridge {
|
|||
close() {
|
||||
this.wss?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP server calls this on shutdown
|
||||
*/
|
||||
disconnect() {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,42 +6,55 @@
|
|||
* and GitNexus code intelligence running in the browser.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { setupCommand } from './commands/setup.js';
|
||||
import { serveCommand } from './commands/serve.js';
|
||||
import { daemonCommand } from './commands/daemon.js';
|
||||
import { generateGuidanceCommand } from './commands/generate-guidance.js';
|
||||
/**
|
||||
* Minimal CLI:
|
||||
* - Default: start MCP stdio server + local browser WebSocket bridge
|
||||
* - Optional: `serve` alias, and `--port <port>`
|
||||
*
|
||||
* This is designed for MCP clients (Cursor/Claude/Windsurf) which spawn this
|
||||
* process automatically; users should not need to run commands manually.
|
||||
*/
|
||||
|
||||
const program = new Command();
|
||||
function parsePort(argv: string[]): string {
|
||||
const portFlagIndex = argv.findIndex((a) => a === '--port' || a === '-p');
|
||||
if (portFlagIndex !== -1) {
|
||||
const value = argv[portFlagIndex + 1];
|
||||
if (value) return value;
|
||||
}
|
||||
// Support `--port=54319`
|
||||
const portEq = argv.find((a) => a.startsWith('--port='));
|
||||
if (portEq) return portEq.split('=')[1] || '54319';
|
||||
return '54319';
|
||||
}
|
||||
|
||||
program
|
||||
.name('gitnexus-mcp')
|
||||
.description('MCP bridge for GitNexus code intelligence')
|
||||
.version('0.1.0');
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const first = argv[0];
|
||||
const port = parsePort(argv);
|
||||
|
||||
program
|
||||
.command('setup')
|
||||
.description('Detect AI tools and configure MCP settings')
|
||||
.action(setupCommand);
|
||||
// Allow `gitnexus-mcp serve` for compatibility, but default to serve anyway
|
||||
if (!first || first === 'serve') {
|
||||
await serveCommand({ port });
|
||||
return;
|
||||
}
|
||||
|
||||
program
|
||||
.command('daemon')
|
||||
.description('Start the MCP daemon (run this once in background)')
|
||||
.option('-p, --port <port>', 'WebSocket port', '54319')
|
||||
.action(daemonCommand);
|
||||
|
||||
program
|
||||
.command('serve')
|
||||
.description('Start MCP server for an AI tool (connects to daemon)')
|
||||
.option('-p, --port <port>', 'Daemon port to connect to', '54319')
|
||||
.action(serveCommand);
|
||||
|
||||
program
|
||||
.command('generate-guidance')
|
||||
.description('Generate AI assistant guidance files (.cursorrules, AGENTS.md)')
|
||||
.option('-o, --output <dir>', 'Output directory', '.')
|
||||
.action(generateGuidanceCommand);
|
||||
|
||||
program.parse();
|
||||
// Minimal help for unknown commands
|
||||
if (first === '--help' || first === '-h') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('gitnexus-mcp\n\nUsage:\n gitnexus-mcp [serve] [--port <port>]\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Unknown command: ${first}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Usage: gitnexus-mcp [serve] [--port <port>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,239 +0,0 @@
|
|||
/**
|
||||
* Daemon Command
|
||||
*
|
||||
* Runs a persistent daemon that acts as the central hub for:
|
||||
* - Browser connections (GitNexus web app)
|
||||
* - MCP server connections (from AI tools like Cursor, Antigravity)
|
||||
*
|
||||
* This allows multiple AI tools to share the same browser bridge.
|
||||
* Also stores codebase context sent by the browser for MCP resource exposure.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
|
||||
interface BridgeMessage {
|
||||
id: string;
|
||||
method?: string;
|
||||
params?: any;
|
||||
result?: any;
|
||||
error?: { message: string };
|
||||
source?: 'browser' | 'mcp';
|
||||
type?: 'context' | 'tool_call' | 'tool_result';
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codebase context sent from browser
|
||||
*/
|
||||
interface CodebaseContext {
|
||||
projectName: string;
|
||||
stats: {
|
||||
fileCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
interfaceCount: number;
|
||||
methodCount: number;
|
||||
};
|
||||
hotspots: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
filePath: string;
|
||||
connections: number;
|
||||
}>;
|
||||
folderTree: string;
|
||||
}
|
||||
|
||||
interface DaemonOptions {
|
||||
port: string;
|
||||
}
|
||||
|
||||
// Module-level state for context (so MCP servers can access it)
|
||||
let currentContext: CodebaseContext | null = null;
|
||||
|
||||
export function getCodebaseContext(): CodebaseContext | null {
|
||||
return currentContext;
|
||||
}
|
||||
|
||||
export async function daemonCommand(options: DaemonOptions) {
|
||||
const port = parseInt(options.port, 10);
|
||||
|
||||
let browserClient: WebSocket | null = null;
|
||||
const mcpClients: Set<WebSocket> = new Set();
|
||||
const pendingRequests: Map<string, WebSocket> = new Map(); // request ID → which MCP client sent it
|
||||
|
||||
// Track agent sessions for multi-instance support
|
||||
interface AgentSession {
|
||||
agentType: string; // e.g., "Cursor", "Claude"
|
||||
instanceId: number; // e.g., 1, 2, 3 for multiple instances
|
||||
sessionId: string; // unique ID
|
||||
}
|
||||
const agentSessions: Map<WebSocket, AgentSession> = new Map();
|
||||
const agentInstanceCounters: Map<string, number> = new Map(); // agentType → next instance number
|
||||
|
||||
const wss = new WebSocketServer({ port });
|
||||
|
||||
console.log(`🔌 GitNexus MCP Daemon running on port ${port}`);
|
||||
console.log(' Waiting for connections...\n');
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
// Determine client type from URL path
|
||||
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
||||
const clientType = url.pathname === '/mcp' ? 'mcp' : 'browser';
|
||||
|
||||
if (clientType === 'browser') {
|
||||
// Browser connection
|
||||
if (browserClient) {
|
||||
browserClient.close();
|
||||
}
|
||||
browserClient = ws;
|
||||
console.log('✅ Browser connected');
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg: BridgeMessage = JSON.parse(data.toString());
|
||||
|
||||
// Handle context update from browser
|
||||
if (msg.type === 'context' && msg.params) {
|
||||
currentContext = msg.params as CodebaseContext;
|
||||
console.log(`📊 Context received: ${currentContext.projectName} (${currentContext.stats.fileCount} files)`);
|
||||
|
||||
// Broadcast context update to all MCP clients
|
||||
const contextNotification = JSON.stringify({
|
||||
type: 'context_update',
|
||||
context: currentContext,
|
||||
});
|
||||
mcpClients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(contextNotification);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Response from browser - route back to the MCP client that sent the request
|
||||
if (msg.id && pendingRequests.has(msg.id)) {
|
||||
const mcpClient = pendingRequests.get(msg.id)!;
|
||||
pendingRequests.delete(msg.id);
|
||||
if (mcpClient.readyState === WebSocket.OPEN) {
|
||||
mcpClient.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse browser message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (browserClient === ws) {
|
||||
browserClient = null;
|
||||
currentContext = null; // Clear context when browser disconnects
|
||||
console.log('❌ Browser disconnected');
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
// MCP server connection
|
||||
mcpClients.add(ws);
|
||||
console.log(`✅ MCP client connected (total: ${mcpClients.size})`);
|
||||
|
||||
// Send current context to new MCP client if available
|
||||
if (currentContext) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'context_update',
|
||||
context: currentContext,
|
||||
}));
|
||||
}
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg: BridgeMessage = JSON.parse(data.toString());
|
||||
|
||||
// Handle context request
|
||||
if (msg.method === 'getContext') {
|
||||
ws.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: currentContext,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Tool call request from MCP server - forward to browser
|
||||
if (msg.method && msg.id) {
|
||||
// Handle session tracking
|
||||
let session = agentSessions.get(ws);
|
||||
const rawAgentName = msg.agentName || 'Unknown';
|
||||
|
||||
if (!session) {
|
||||
// First time seeing this connection - assign instance ID
|
||||
const currentCount = agentInstanceCounters.get(rawAgentName) || 0;
|
||||
const newCount = currentCount + 1;
|
||||
agentInstanceCounters.set(rawAgentName, newCount);
|
||||
|
||||
session = {
|
||||
agentType: rawAgentName,
|
||||
instanceId: newCount,
|
||||
sessionId: `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
};
|
||||
agentSessions.set(ws, session);
|
||||
console.log(`🆕 Registered ${rawAgentName} #${newCount}`);
|
||||
|
||||
// Notify browser about new agent (optional, mainly for UI updates if needed later)
|
||||
// browserClient?.send(JSON.stringify({ type: 'agent_info', ... }));
|
||||
}
|
||||
|
||||
// Construct display name (e.g., "Claude #1")
|
||||
// If it's the first instance, maybe just keep "Claude" or do we always want numbers?
|
||||
// User asked for tracking multiple, so "Claude #1" is clearer even for the first if we expect multiples.
|
||||
// Let's use format "Name #N"
|
||||
const uniqueAgentName = `${session.agentType} #${session.instanceId}`;
|
||||
|
||||
if (browserClient && browserClient.readyState === WebSocket.OPEN) {
|
||||
pendingRequests.set(msg.id, ws);
|
||||
// Forward with unique name
|
||||
const forwardMsg = { ...msg, agentName: uniqueAgentName };
|
||||
browserClient.send(JSON.stringify(forwardMsg));
|
||||
} else {
|
||||
// No browser connected
|
||||
ws.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
error: { message: 'GitNexus browser not connected. Open GitNexus and enable MCP toggle.' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse MCP message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const session = agentSessions.get(ws);
|
||||
if (session) {
|
||||
console.log(`❌ ${session.agentType} #${session.instanceId} disconnected`);
|
||||
agentSessions.delete(ws);
|
||||
}
|
||||
mcpClients.delete(ws);
|
||||
console.log(`Remaining clients: ${mcpClients.size}`);
|
||||
});
|
||||
}
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error.message);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.error(`❌ Port ${port} is already in use.`);
|
||||
console.error(' A daemon may already be running. Check with: lsof -i :54319');
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('Server error:', error);
|
||||
});
|
||||
|
||||
// Keep the daemon running
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n👋 Shutting down daemon...');
|
||||
wss.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
/**
|
||||
* Generate Guidance Command
|
||||
*
|
||||
* Creates AI assistant guidance files for a project:
|
||||
* - .cursor/rules/gitnexus.mdc - Cursor skills file
|
||||
* - AGENTS.md - Generic tool usage patterns for Claude Code etc.
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface GenerateOptions {
|
||||
output: string;
|
||||
}
|
||||
|
||||
// Cursor uses MDC format (Markdown with frontmatter)
|
||||
const CURSOR_RULES_CONTENT = `---
|
||||
description: GitNexus MCP code intelligence tools
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GitNexus Code Intelligence
|
||||
|
||||
This project uses GitNexus MCP for code understanding. You have access to these tools:
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
### search
|
||||
Semantic search across code. Returns functions, classes, files matching meaning.
|
||||
- Use for: Finding code by description, not exact text
|
||||
- Example: "authentication logic", "database connection handling"
|
||||
|
||||
### cypher
|
||||
Direct KuzuDB graph queries for relationship traversal.
|
||||
- Pattern: \`MATCH (f:Function)-[:CodeRelation {type:'CALLS'}]->(g:Function) RETURN f.name, g.name\`
|
||||
- Node types: File, Folder, Function, Class, Interface, Method, CodeElement
|
||||
- Relation types: CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS
|
||||
|
||||
### blastRadius
|
||||
Find all code affected by changing a node. Returns N-hop connections.
|
||||
- Use for: Before refactoring, understanding impact of changes
|
||||
|
||||
### highlight
|
||||
Highlight nodes in the graph visualization.
|
||||
|
||||
### context
|
||||
Get project overview: stats, hotspots, folder tree.
|
||||
- Use for: Understanding project structure
|
||||
|
||||
### grep
|
||||
Regex pattern search across file contents.
|
||||
- Use for: Finding exact patterns, TODOs
|
||||
|
||||
### read
|
||||
Read file content by path. Supports line ranges.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with context** - Understand project structure first
|
||||
2. **Use search for discovery** - Find by meaning, not keywords
|
||||
3. **Use cypher for relationships** - Call chains, imports, inheritance
|
||||
4. **Check blast radius before refactoring** - Understand impact
|
||||
|
||||
## Cypher Examples
|
||||
|
||||
\`\`\`cypher
|
||||
# Find functions that call a specific function
|
||||
MATCH (caller:Function)-[:CodeRelation {type:'CALLS'}]->(target:Function {name: 'authenticate'})
|
||||
RETURN caller.name, caller.filePath
|
||||
|
||||
# Find class inheritance
|
||||
MATCH (child:Class)-[:CodeRelation {type:'EXTENDS'}]->(parent:Class)
|
||||
RETURN child.name, parent.name
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const AGENTS_MD_CONTENT = `# GitNexus Agent Guidelines
|
||||
|
||||
This document describes how to use GitNexus MCP tools for code understanding.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Tool | Use When | Example |
|
||||
|------|----------|---------|
|
||||
| \`search\` | Finding code by meaning | "user authentication flow" |
|
||||
| \`cypher\` | Traversing relationships | Finding what calls a function |
|
||||
| \`blastRadius\` | Before refactoring | What depends on this class? |
|
||||
| \`context\` | Understanding project | Get overview, hotspots |
|
||||
| \`grep\` | Exact pattern match | Find all TODO comments |
|
||||
| \`read\` | Reading file content | View actual code |
|
||||
| \`highlight\` | Visual feedback | Show user what you found |
|
||||
|
||||
## Node Types
|
||||
File, Folder, Function, Class, Interface, Method, CodeElement
|
||||
|
||||
## Relation Types
|
||||
CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS
|
||||
|
||||
## Cypher Query Examples
|
||||
|
||||
\`\`\`cypher
|
||||
-- Find functions called by a specific function
|
||||
MATCH (f:Function {name: 'handleRequest'})-[:CodeRelation {type:'CALLS'}]->(called)
|
||||
RETURN called.name, called.filePath
|
||||
|
||||
-- Find class hierarchy
|
||||
MATCH (c:Class)-[:CodeRelation {type:'EXTENDS'}*1..3]->(parent)
|
||||
RETURN c.name, collect(parent.name) as ancestors
|
||||
\`\`\`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Run \`context\` to understand project structure
|
||||
2. Use \`search\` to find relevant code
|
||||
3. Use \`blastRadius\` before making changes
|
||||
4. Use \`read\` to examine actual code
|
||||
`;
|
||||
|
||||
export async function generateGuidanceCommand(options: GenerateOptions): Promise<void> {
|
||||
const outputDir = options.output || '.';
|
||||
|
||||
console.log(chalk.cyan('\n🔧 Generating AI agent guidance files...\n'));
|
||||
|
||||
try {
|
||||
// Create .cursor/rules directory
|
||||
const cursorRulesDir = join(outputDir, '.cursor', 'rules');
|
||||
if (!existsSync(cursorRulesDir)) {
|
||||
await mkdir(cursorRulesDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write .cursor/rules/gitnexus.mdc
|
||||
const cursorRulePath = join(cursorRulesDir, 'gitnexus.mdc');
|
||||
await writeFile(cursorRulePath, CURSOR_RULES_CONTENT, 'utf-8');
|
||||
console.log(chalk.green('✓'), 'Created', chalk.bold('.cursor/rules/gitnexus.mdc'));
|
||||
|
||||
// Write AGENTS.md
|
||||
const agentsPath = join(outputDir, 'AGENTS.md');
|
||||
await writeFile(agentsPath, AGENTS_MD_CONTENT, 'utf-8');
|
||||
console.log(chalk.green('✓'), 'Created', chalk.bold('AGENTS.md'));
|
||||
|
||||
console.log(chalk.green('\n✅ Guidance files generated successfully!'));
|
||||
console.log(chalk.dim('\nThese files help AI assistants understand how to use GitNexus MCP tools.'));
|
||||
console.log(chalk.dim('Commit them to your repo so all team members benefit.\n'));
|
||||
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error generating guidance files:'), error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
*
|
||||
* Starts the MCP server that bridges external AI agents to GitNexus.
|
||||
* - Listens on stdio for MCP protocol (from AI tools)
|
||||
* - Connects to daemon via WebSocket (client mode)
|
||||
* - Hosts a local WebSocket bridge for the GitNexus browser app
|
||||
*/
|
||||
|
||||
import { startMCPServer } from '../mcp/server.js';
|
||||
import { DaemonClient } from '../bridge/daemon-client.js';
|
||||
import { WebSocketBridge } from '../bridge/websocket-server.js';
|
||||
|
||||
interface ServeOptions {
|
||||
port: string;
|
||||
|
|
@ -16,15 +16,13 @@ interface ServeOptions {
|
|||
export async function serveCommand(options: ServeOptions) {
|
||||
const port = parseInt(options.port, 10);
|
||||
|
||||
// Connect to daemon as a WebSocket client
|
||||
const client = new DaemonClient(port);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
// Daemon not running - provide helpful error
|
||||
console.error('Failed to connect to GitNexus daemon.');
|
||||
console.error('Make sure the daemon is running: npx gitnexus-mcp daemon');
|
||||
// Start local WebSocket bridge (browser connects to ws://localhost:<port>)
|
||||
const client = new WebSocketBridge(port);
|
||||
const started = await client.start();
|
||||
|
||||
if (!started) {
|
||||
console.error(`Failed to start GitNexus browser bridge on port ${port}.`);
|
||||
console.error('Another process is already using this port.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
/**
|
||||
* Setup Command
|
||||
*
|
||||
* Detects installed AI tools (Cursor, Claude Code, Windsurf) and
|
||||
* configures their MCP settings to use GitNexus.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { detectIDEs, type IDE } from '../config/detect-ides.js';
|
||||
import { injectMCPConfig } from '../config/inject-config.js';
|
||||
import { getBridgePath } from '../config/paths.js';
|
||||
|
||||
export async function setupCommand() {
|
||||
console.log(chalk.blue('\n🔧 GitNexus MCP Setup\n'));
|
||||
|
||||
// Step 1: Get bridge path (this CLI itself is the bridge)
|
||||
const spinner = ora('Locating bridge...').start();
|
||||
const bridgePath = getBridgePath();
|
||||
spinner.succeed(`Bridge located at ${chalk.dim(bridgePath)}`);
|
||||
|
||||
// Step 2: Detect AI tools
|
||||
spinner.start('Detecting AI tools...');
|
||||
const ides = await detectIDEs();
|
||||
|
||||
if (ides.length === 0) {
|
||||
spinner.warn('No supported AI tools detected');
|
||||
console.log(chalk.yellow('\nSupported tools: Cursor, Claude Code, Windsurf, VS Code'));
|
||||
console.log('Install one of these tools to use GitNexus MCP.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.succeed(`Found: ${ides.map(i => chalk.cyan(i.name)).join(', ')}`);
|
||||
|
||||
// Step 3: Inject MCP config into each IDE
|
||||
for (const ide of ides) {
|
||||
spinner.start(`Configuring ${ide.name}...`);
|
||||
try {
|
||||
await injectMCPConfig(ide, bridgePath);
|
||||
spinner.succeed(`Configured ${chalk.cyan(ide.name)}`);
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to configure ${ide.name}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Success message
|
||||
console.log(chalk.green('\n✅ Setup complete!\n'));
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Open GitNexus in your browser');
|
||||
console.log(' 2. Load a codebase');
|
||||
console.log(' 3. Click the MCP toggle to connect');
|
||||
console.log(' 4. Your AI tools can now use GitNexus!\n');
|
||||
|
||||
console.log(chalk.dim('Available tools: search, cypher, blastRadius, highlight\n'));
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/**
|
||||
* IDE Detection
|
||||
*
|
||||
* Detects installed AI coding tools by checking for their config directories.
|
||||
* Supports Windows, macOS, and Linux paths.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { homedir, platform } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface IDE {
|
||||
name: string;
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
interface IDECheck {
|
||||
name: string;
|
||||
// Paths relative to home directory (first found is used)
|
||||
paths: {
|
||||
win32: string[];
|
||||
darwin: string[];
|
||||
linux: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const IDE_CHECKS: IDECheck[] = [
|
||||
{
|
||||
name: 'Cursor',
|
||||
paths: {
|
||||
win32: ['AppData/Roaming/Cursor/User/globalStorage/mcp.json', '.cursor/mcp.json'],
|
||||
darwin: ['.cursor/mcp.json', 'Library/Application Support/Cursor/User/globalStorage/mcp.json'],
|
||||
linux: ['.cursor/mcp.json', '.config/Cursor/User/globalStorage/mcp.json'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Claude Code',
|
||||
paths: {
|
||||
win32: ['.claude/mcp.json', 'AppData/Roaming/Claude/mcp.json'],
|
||||
darwin: ['.claude/mcp.json', 'Library/Application Support/Claude/mcp.json'],
|
||||
linux: ['.claude/mcp.json', '.config/Claude/mcp.json'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Windsurf',
|
||||
paths: {
|
||||
win32: ['.windsurf/mcp.json', '.codeium/windsurf/mcp.json'],
|
||||
darwin: ['.windsurf/mcp.json', '.codeium/windsurf/mcp.json'],
|
||||
linux: ['.windsurf/mcp.json', '.codeium/windsurf/mcp.json'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'VS Code',
|
||||
paths: {
|
||||
win32: ['AppData/Roaming/Code/User/globalStorage/mcp.json'],
|
||||
darwin: ['Library/Application Support/Code/User/globalStorage/mcp.json'],
|
||||
linux: ['.config/Code/User/globalStorage/mcp.json'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Antigravity',
|
||||
paths: {
|
||||
win32: ['.gemini/antigravity/mcp_config.json'],
|
||||
darwin: ['.gemini/antigravity/mcp_config.json'],
|
||||
linux: ['.gemini/antigravity/mcp_config.json'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'OpenCode',
|
||||
paths: {
|
||||
win32: ['.config/opencode/opencode.json', 'opencode.json'],
|
||||
darwin: ['.config/opencode/opencode.json', 'opencode.json'],
|
||||
linux: ['.config/opencode/opencode.json', 'opencode.json'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function detectIDEs(): Promise<IDE[]> {
|
||||
const home = homedir();
|
||||
const os = platform() as 'win32' | 'darwin' | 'linux';
|
||||
const ides: IDE[] = [];
|
||||
|
||||
for (const check of IDE_CHECKS) {
|
||||
const paths = check.paths[os] || check.paths.linux;
|
||||
|
||||
for (const relPath of paths) {
|
||||
const configPath = join(home, relPath);
|
||||
// Check if the parent directory exists (IDE is installed)
|
||||
const dirPath = configPath.replace(/[/\\]mcp\.json$/, '');
|
||||
const parentDir = dirPath.split(/[/\\]/).slice(0, -1).join('/');
|
||||
|
||||
// Look for IDE installation markers
|
||||
const ideDirExists = existsSync(dirPath) || existsSync(parentDir);
|
||||
|
||||
if (ideDirExists) {
|
||||
ides.push({ name: check.name, configPath });
|
||||
break; // Found this IDE, move to next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ides;
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
/**
|
||||
* MCP Config Injection
|
||||
*
|
||||
* Adds GitNexus MCP server to an IDE's configuration.
|
||||
* Handles different config formats for different tools.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { type IDE } from './detect-ides.js';
|
||||
|
||||
interface MCPServerEntry {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface StandardMCPConfig {
|
||||
mcpServers?: Record<string, MCPServerEntry>;
|
||||
}
|
||||
|
||||
// OpenCode uses a different format with 'mcp' key
|
||||
interface OpenCodeConfig {
|
||||
mcp?: Record<string, {
|
||||
type?: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export async function injectMCPConfig(ide: IDE, bridgePath: string): Promise<void> {
|
||||
// Create directory structure if needed
|
||||
if (!existsSync(ide.configPath)) {
|
||||
const dir = dirname(ide.configPath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Handle OpenCode's different format
|
||||
if (ide.name === 'OpenCode') {
|
||||
await injectOpenCodeConfig(ide.configPath, bridgePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard MCP format (Cursor, Claude Code, Windsurf, VS Code, Antigravity)
|
||||
let config: StandardMCPConfig = { mcpServers: {} };
|
||||
|
||||
// Read existing config if present
|
||||
if (existsSync(ide.configPath)) {
|
||||
try {
|
||||
const content = readFileSync(ide.configPath, 'utf-8');
|
||||
config = JSON.parse(content);
|
||||
} catch {
|
||||
// Ignore parse errors, use empty config
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
config.mcpServers = config.mcpServers || {};
|
||||
|
||||
// Add GitNexus MCP server configuration
|
||||
config.mcpServers.gitnexus = {
|
||||
command: bridgePath,
|
||||
args: ['serve'],
|
||||
};
|
||||
|
||||
// Write updated config
|
||||
writeFileSync(ide.configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
async function injectOpenCodeConfig(configPath: string, bridgePath: string): Promise<void> {
|
||||
let config: OpenCodeConfig = {};
|
||||
|
||||
// Read existing config if present
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
config = JSON.parse(content);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure mcp object exists
|
||||
config.mcp = config.mcp || {};
|
||||
|
||||
// Add GitNexus MCP server (OpenCode format)
|
||||
config.mcp.gitnexus = {
|
||||
type: 'local',
|
||||
command: bridgePath,
|
||||
args: ['serve'],
|
||||
};
|
||||
|
||||
// Write updated config
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
/**
|
||||
* Path Utilities
|
||||
*
|
||||
* Resolves paths for the MCP bridge executable.
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
/**
|
||||
* Get the path to this CLI executable.
|
||||
* When installed via npm, this will be in node_modules/.bin/
|
||||
* The MCP config needs to point to the actual executable.
|
||||
*/
|
||||
export function getBridgePath(): string {
|
||||
// When running as ES module, we need to resolve from import.meta.url
|
||||
// But for the installed package, we want the bin path
|
||||
|
||||
// Get the path to this module
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Go up from dist/config/ to the package root, then to the bin
|
||||
const packageRoot = resolve(__dirname, '..', '..');
|
||||
const binPath = resolve(packageRoot, 'dist', 'cli.js');
|
||||
|
||||
// Return as node command since it's a JS file
|
||||
return `node ${binPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's GitNexus config directory
|
||||
*/
|
||||
export function getGitNexusConfigDir(): string {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
return resolve(home, '.gitnexus');
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { GITNEXUS_TOOLS } from './tools.js';
|
||||
import type { DaemonClient, CodebaseContext } from '../bridge/daemon-client.js';
|
||||
import type { CodebaseContext } from '../bridge/websocket-server.js';
|
||||
|
||||
// Interface for anything that can call tools (DaemonClient or WebSocketBridge)
|
||||
interface ToolCaller {
|
||||
|
|
|
|||
|
|
@ -135,24 +135,29 @@ RETURNS: {filePath, content, language, lines}`,
|
|||
{
|
||||
name: 'blastRadius',
|
||||
description: `Analyze the impact of changing a code element.
|
||||
Returns all nodes that would be affected by modifying the target.
|
||||
Returns all nodes affected by modifying the target, with distance, edge type, and confidence.
|
||||
|
||||
USE BEFORE making changes to understand ripple effects.
|
||||
Shows direct and indirect dependencies up to N hops.
|
||||
|
||||
RETURNS: {
|
||||
target: {...},
|
||||
affected: [{name, type, filePath, distance}...],
|
||||
totalAffected: number,
|
||||
riskLevel: "low" | "medium" | "high"
|
||||
}`,
|
||||
Output format (compact tabular):
|
||||
Type|Name|File:Line|EdgeType|Confidence%
|
||||
|
||||
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS
|
||||
Confidence: 100% = certain, <80% = fuzzy match [fuzzy]
|
||||
|
||||
Depth groups:
|
||||
- d=1: WILL BREAK (direct callers/importers)
|
||||
- d=2: LIKELY AFFECTED (indirect)
|
||||
- d=3: MAY NEED TESTING (transitive)`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeId: { type: 'string', description: 'Node ID or name to analyze' },
|
||||
hops: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 },
|
||||
target: { type: 'string', description: 'Name of function, class, or file to analyze' },
|
||||
direction: { type: 'string', description: 'upstream (what depends on this) or downstream (what this depends on)' },
|
||||
maxDepth: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 },
|
||||
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, CONTAINS, DEFINES (default: usage-based)' },
|
||||
},
|
||||
required: ['nodeId'],
|
||||
required: ['target', 'direction'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue