gitnexus 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Setup Command
3
+ *
4
+ * One-time global MCP configuration writer.
5
+ * Detects installed AI editors and writes the appropriate MCP config
6
+ * so the GitNexus MCP server is available in all projects.
7
+ */
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import { getGlobalDir } from '../storage/repo-manager.js';
12
+ /**
13
+ * The MCP server entry for all editors
14
+ */
15
+ function getMcpEntry() {
16
+ return {
17
+ command: 'npx',
18
+ args: ['-y', 'gitnexus', 'mcp'],
19
+ };
20
+ }
21
+ /**
22
+ * Merge gitnexus entry into an existing MCP config JSON object.
23
+ * Returns the updated config.
24
+ */
25
+ function mergeMcpConfig(existing) {
26
+ if (!existing || typeof existing !== 'object') {
27
+ existing = {};
28
+ }
29
+ if (!existing.mcpServers || typeof existing.mcpServers !== 'object') {
30
+ existing.mcpServers = {};
31
+ }
32
+ existing.mcpServers.gitnexus = getMcpEntry();
33
+ return existing;
34
+ }
35
+ /**
36
+ * Try to read a JSON file, returning null if it doesn't exist or is invalid.
37
+ */
38
+ async function readJsonFile(filePath) {
39
+ try {
40
+ const raw = await fs.readFile(filePath, 'utf-8');
41
+ return JSON.parse(raw);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /**
48
+ * Write JSON to a file, creating parent directories if needed.
49
+ */
50
+ async function writeJsonFile(filePath, data) {
51
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
52
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
53
+ }
54
+ /**
55
+ * Check if a directory exists
56
+ */
57
+ async function dirExists(dirPath) {
58
+ try {
59
+ const stat = await fs.stat(dirPath);
60
+ return stat.isDirectory();
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ // ─── Editor-specific setup ─────────────────────────────────────────
67
+ async function setupCursor(result) {
68
+ const cursorDir = path.join(os.homedir(), '.cursor');
69
+ if (!(await dirExists(cursorDir))) {
70
+ result.skipped.push('Cursor (not installed)');
71
+ return;
72
+ }
73
+ const mcpPath = path.join(cursorDir, 'mcp.json');
74
+ try {
75
+ const existing = await readJsonFile(mcpPath);
76
+ const updated = mergeMcpConfig(existing);
77
+ await writeJsonFile(mcpPath, updated);
78
+ result.configured.push('Cursor');
79
+ }
80
+ catch (err) {
81
+ result.errors.push(`Cursor: ${err.message}`);
82
+ }
83
+ }
84
+ async function setupClaudeCode(result) {
85
+ // Claude Code uses `claude mcp add` — we just print the command
86
+ // Check for common Claude Code indicators
87
+ const claudeDir = path.join(os.homedir(), '.claude');
88
+ const hasClaude = await dirExists(claudeDir);
89
+ if (!hasClaude) {
90
+ result.skipped.push('Claude Code (not installed)');
91
+ return;
92
+ }
93
+ // Claude Code uses a JSON settings file at ~/.claude.json or claude mcp add
94
+ console.log('');
95
+ console.log(' Claude Code detected. Run this command to add GitNexus:');
96
+ console.log('');
97
+ console.log(' claude mcp add gitnexus -- npx -y gitnexus mcp');
98
+ console.log('');
99
+ result.configured.push('Claude Code (manual step printed)');
100
+ }
101
+ async function setupOpenCode(result) {
102
+ const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
103
+ if (!(await dirExists(opencodeDir))) {
104
+ result.skipped.push('OpenCode (not installed)');
105
+ return;
106
+ }
107
+ const configPath = path.join(opencodeDir, 'config.json');
108
+ try {
109
+ const existing = await readJsonFile(configPath);
110
+ const config = existing || {};
111
+ if (!config.mcp)
112
+ config.mcp = {};
113
+ config.mcp.gitnexus = getMcpEntry();
114
+ await writeJsonFile(configPath, config);
115
+ result.configured.push('OpenCode');
116
+ }
117
+ catch (err) {
118
+ result.errors.push(`OpenCode: ${err.message}`);
119
+ }
120
+ }
121
+ // ─── Main command ──────────────────────────────────────────────────
122
+ export const setupCommand = async () => {
123
+ console.log('');
124
+ console.log(' GitNexus Setup');
125
+ console.log(' ==============');
126
+ console.log('');
127
+ // Ensure global directory exists
128
+ const globalDir = getGlobalDir();
129
+ await fs.mkdir(globalDir, { recursive: true });
130
+ const result = {
131
+ configured: [],
132
+ skipped: [],
133
+ errors: [],
134
+ };
135
+ // Detect and configure each editor
136
+ await setupCursor(result);
137
+ await setupClaudeCode(result);
138
+ await setupOpenCode(result);
139
+ // Print results
140
+ if (result.configured.length > 0) {
141
+ console.log(' Configured:');
142
+ for (const name of result.configured) {
143
+ console.log(` + ${name}`);
144
+ }
145
+ }
146
+ if (result.skipped.length > 0) {
147
+ console.log('');
148
+ console.log(' Skipped:');
149
+ for (const name of result.skipped) {
150
+ console.log(` - ${name}`);
151
+ }
152
+ }
153
+ if (result.errors.length > 0) {
154
+ console.log('');
155
+ console.log(' Errors:');
156
+ for (const err of result.errors) {
157
+ console.log(` ! ${err}`);
158
+ }
159
+ }
160
+ console.log('');
161
+ console.log(' Next steps:');
162
+ console.log(' 1. cd into any git repo');
163
+ console.log(' 2. Run: gitnexus analyze');
164
+ console.log(' 3. Open the repo in your editor — MCP is ready!');
165
+ console.log('');
166
+ };
@@ -17,6 +17,7 @@ export interface BM25SearchResult {
17
17
  *
18
18
  * @param query - Search query string
19
19
  * @param limit - Maximum results
20
+ * @param repoId - If provided, queries will be routed via the MCP connection pool
20
21
  * @returns Ranked search results from FTS indexes
21
22
  */
22
- export declare const searchFTSFromKuzu: (query: string, limit?: number) => Promise<BM25SearchResult[]>;
23
+ export declare const searchFTSFromKuzu: (query: string, limit?: number, repoId?: string) => Promise<BM25SearchResult[]>;
@@ -5,6 +5,33 @@
5
5
  * Always reads from the database (no cached state to drift).
6
6
  */
7
7
  import { queryFTS } from '../kuzu/kuzu-adapter.js';
8
+ /**
9
+ * Execute a single FTS query via a custom executor (for MCP connection pool).
10
+ * Returns the same shape as core queryFTS.
11
+ */
12
+ async function queryFTSViaExecutor(executor, tableName, indexName, query, limit) {
13
+ const escapedQuery = query.replace(/'/g, "''");
14
+ const cypher = `
15
+ CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false)
16
+ RETURN node, score
17
+ ORDER BY score DESC
18
+ LIMIT ${limit}
19
+ `;
20
+ try {
21
+ const rows = await executor(cypher);
22
+ return rows.map((row) => {
23
+ const node = row.node || row[0] || {};
24
+ const score = row.score ?? row[1] ?? 0;
25
+ return {
26
+ filePath: node.filePath || '',
27
+ score: typeof score === 'number' ? score : parseFloat(score) || 0,
28
+ };
29
+ });
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
8
35
  /**
9
36
  * Search using KuzuDB's built-in FTS (always fresh, reads from disk)
10
37
  *
@@ -13,16 +40,31 @@ import { queryFTS } from '../kuzu/kuzu-adapter.js';
13
40
  *
14
41
  * @param query - Search query string
15
42
  * @param limit - Maximum results
43
+ * @param repoId - If provided, queries will be routed via the MCP connection pool
16
44
  * @returns Ranked search results from FTS indexes
17
45
  */
18
- export const searchFTSFromKuzu = async (query, limit = 20) => {
19
- // Search multiple tables with searchable content
20
- const [fileResults, functionResults, classResults, methodResults] = await Promise.all([
21
- queryFTS('File', 'file_fts', query, limit, false).catch(() => []),
22
- queryFTS('Function', 'function_fts', query, limit, false).catch(() => []),
23
- queryFTS('Class', 'class_fts', query, limit, false).catch(() => []),
24
- queryFTS('Method', 'method_fts', query, limit, false).catch(() => []),
25
- ]);
46
+ export const searchFTSFromKuzu = async (query, limit = 20, repoId) => {
47
+ let fileResults, functionResults, classResults, methodResults;
48
+ if (repoId) {
49
+ // Use MCP connection pool via dynamic import
50
+ const { executeQuery } = await import('../../mcp/core/kuzu-adapter.js');
51
+ const executor = (cypher) => executeQuery(repoId, cypher);
52
+ [fileResults, functionResults, classResults, methodResults] = await Promise.all([
53
+ queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit),
54
+ queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit),
55
+ queryFTSViaExecutor(executor, 'Class', 'class_fts', query, limit),
56
+ queryFTSViaExecutor(executor, 'Method', 'method_fts', query, limit),
57
+ ]);
58
+ }
59
+ else {
60
+ // Use core kuzu adapter (CLI / pipeline context)
61
+ [fileResults, functionResults, classResults, methodResults] = await Promise.all([
62
+ queryFTS('File', 'file_fts', query, limit, false).catch(() => []),
63
+ queryFTS('Function', 'function_fts', query, limit, false).catch(() => []),
64
+ queryFTS('Class', 'class_fts', query, limit, false).catch(() => []),
65
+ queryFTS('Method', 'method_fts', query, limit, false).catch(() => []),
66
+ ]);
67
+ }
26
68
  // Merge results by filePath, summing scores for same file
27
69
  const merged = new Map();
28
70
  const addResults = (results) => {
@@ -1,23 +1,25 @@
1
1
  /**
2
- * KuzuDB Adapter (Persistent Connection)
2
+ * KuzuDB Adapter (Connection Pool)
3
3
  *
4
- * Holds a single database connection for the lifetime of the MCP session.
5
- * This is safe since the watcher has been removed -- only one process
6
- * accesses the database at a time.
4
+ * Manages a pool of KuzuDB connections keyed by repoId.
5
+ * Connections are lazily opened on first query and evicted
6
+ * after idle timeout or when pool exceeds max size (LRU).
7
7
  */
8
8
  /**
9
- * Initialize with a persistent connection to the database
9
+ * Initialize (or reuse) a connection for a specific repo
10
10
  */
11
- export declare const initKuzu: (path: string) => Promise<void>;
11
+ export declare const initKuzu: (repoId: string, dbPath: string) => Promise<void>;
12
12
  /**
13
- * Execute a query using the persistent connection
13
+ * Execute a query on a specific repo's connection
14
14
  */
15
- export declare const executeQuery: (cypher: string) => Promise<any[]>;
15
+ export declare const executeQuery: (repoId: string, cypher: string) => Promise<any[]>;
16
16
  /**
17
- * Close the persistent connection
17
+ * Close one or all connections.
18
+ * If repoId is provided, close only that connection.
19
+ * If omitted, close all connections in the pool.
18
20
  */
19
- export declare const closeKuzu: () => Promise<void>;
21
+ export declare const closeKuzu: (repoId?: string) => Promise<void>;
20
22
  /**
21
- * Check if the database connection is active
23
+ * Check if a specific repo's connection is active
22
24
  */
23
- export declare const isKuzuReady: () => boolean;
25
+ export declare const isKuzuReady: (repoId: string) => boolean;
@@ -1,62 +1,126 @@
1
1
  /**
2
- * KuzuDB Adapter (Persistent Connection)
2
+ * KuzuDB Adapter (Connection Pool)
3
3
  *
4
- * Holds a single database connection for the lifetime of the MCP session.
5
- * This is safe since the watcher has been removed -- only one process
6
- * accesses the database at a time.
4
+ * Manages a pool of KuzuDB connections keyed by repoId.
5
+ * Connections are lazily opened on first query and evicted
6
+ * after idle timeout or when pool exceeds max size (LRU).
7
7
  */
8
8
  import fs from 'fs/promises';
9
9
  import kuzu from 'kuzu';
10
- let db = null;
11
- let conn = null;
10
+ const pool = new Map();
11
+ const MAX_POOL_SIZE = 5;
12
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
13
+ let idleTimer = null;
12
14
  /**
13
- * Initialize with a persistent connection to the database
15
+ * Start the idle cleanup timer (runs every 60s)
14
16
  */
15
- export const initKuzu = async (path) => {
16
- if (conn)
17
- return; // Already initialized
17
+ function ensureIdleTimer() {
18
+ if (idleTimer)
19
+ return;
20
+ idleTimer = setInterval(() => {
21
+ const now = Date.now();
22
+ for (const [repoId, entry] of pool) {
23
+ if (now - entry.lastUsed > IDLE_TIMEOUT_MS) {
24
+ closeOne(repoId);
25
+ }
26
+ }
27
+ }, 60_000);
28
+ // Don't keep the process alive just for this timer
29
+ if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
30
+ idleTimer.unref();
31
+ }
32
+ }
33
+ /**
34
+ * Evict the least-recently-used connection if pool is at capacity
35
+ */
36
+ function evictLRU() {
37
+ if (pool.size < MAX_POOL_SIZE)
38
+ return;
39
+ let oldestId = null;
40
+ let oldestTime = Infinity;
41
+ for (const [id, entry] of pool) {
42
+ if (entry.lastUsed < oldestTime) {
43
+ oldestTime = entry.lastUsed;
44
+ oldestId = id;
45
+ }
46
+ }
47
+ if (oldestId) {
48
+ closeOne(oldestId);
49
+ }
50
+ }
51
+ /**
52
+ * Close a single pool entry
53
+ */
54
+ function closeOne(repoId) {
55
+ const entry = pool.get(repoId);
56
+ if (!entry)
57
+ return;
58
+ try {
59
+ entry.conn.close();
60
+ }
61
+ catch { }
62
+ try {
63
+ entry.db.close();
64
+ }
65
+ catch { }
66
+ pool.delete(repoId);
67
+ }
68
+ /**
69
+ * Initialize (or reuse) a connection for a specific repo
70
+ */
71
+ export const initKuzu = async (repoId, dbPath) => {
72
+ const existing = pool.get(repoId);
73
+ if (existing) {
74
+ existing.lastUsed = Date.now();
75
+ return;
76
+ }
18
77
  // Check if database exists
19
78
  try {
20
- await fs.stat(path);
79
+ await fs.stat(dbPath);
21
80
  }
22
81
  catch {
23
- throw new Error(`KuzuDB not found at ${path}. Run: gitnexus analyze`);
82
+ throw new Error(`KuzuDB not found at ${dbPath}. Run: gitnexus analyze`);
24
83
  }
25
- db = new kuzu.Database(path);
26
- conn = new kuzu.Connection(db);
84
+ evictLRU();
85
+ const db = new kuzu.Database(dbPath);
86
+ const conn = new kuzu.Connection(db);
87
+ pool.set(repoId, { db, conn, lastUsed: Date.now(), dbPath });
88
+ ensureIdleTimer();
27
89
  };
28
90
  /**
29
- * Execute a query using the persistent connection
91
+ * Execute a query on a specific repo's connection
30
92
  */
31
- export const executeQuery = async (cypher) => {
32
- if (!conn) {
33
- throw new Error('KuzuDB not initialized. Call initKuzu first.');
93
+ export const executeQuery = async (repoId, cypher) => {
94
+ const entry = pool.get(repoId);
95
+ if (!entry) {
96
+ throw new Error(`KuzuDB not initialized for repo "${repoId}". Call initKuzu first.`);
34
97
  }
35
- const queryResult = await conn.query(cypher);
98
+ entry.lastUsed = Date.now();
99
+ const queryResult = await entry.conn.query(cypher);
36
100
  const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
37
101
  const rows = await result.getAll();
38
102
  return rows;
39
103
  };
40
104
  /**
41
- * Close the persistent connection
105
+ * Close one or all connections.
106
+ * If repoId is provided, close only that connection.
107
+ * If omitted, close all connections in the pool.
42
108
  */
43
- export const closeKuzu = async () => {
44
- if (conn) {
45
- try {
46
- await conn.close();
47
- }
48
- catch { }
49
- conn = null;
109
+ export const closeKuzu = async (repoId) => {
110
+ if (repoId) {
111
+ closeOne(repoId);
112
+ return;
50
113
  }
51
- if (db) {
52
- try {
53
- await db.close();
54
- }
55
- catch { }
56
- db = null;
114
+ // Close all
115
+ for (const id of [...pool.keys()]) {
116
+ closeOne(id);
117
+ }
118
+ if (idleTimer) {
119
+ clearInterval(idleTimer);
120
+ idleTimer = null;
57
121
  }
58
122
  };
59
123
  /**
60
- * Check if the database connection is active
124
+ * Check if a specific repo's connection is active
61
125
  */
62
- export const isKuzuReady = () => conn !== null && db !== null;
126
+ export const isKuzuReady = (repoId) => pool.has(repoId);
@@ -1,29 +1,11 @@
1
1
  /**
2
- * Local Backend
2
+ * Local Backend (Multi-Repo)
3
3
  *
4
- * Provides tool implementations using local .gitnexus/ index.
5
- * This enables MCP to work without the browser.
4
+ * Provides tool implementations using local .gitnexus/ indexes.
5
+ * Supports multiple indexed repositories via a global registry.
6
+ * KuzuDB connections are opened lazily per repo on first query.
6
7
  */
7
- export interface RepoMeta {
8
- repoPath: string;
9
- lastCommit: string;
10
- indexedAt: string;
11
- stats?: {
12
- files?: number;
13
- nodes?: number;
14
- edges?: number;
15
- communities?: number;
16
- processes?: number;
17
- };
18
- }
19
- export interface IndexedRepo {
20
- repoPath: string;
21
- storagePath: string;
22
- kuzuPath: string;
23
- metaPath: string;
24
- meta: RepoMeta;
25
- }
26
- export declare function findRepo(startPath: string): Promise<IndexedRepo | null>;
8
+ import { type RegistryEntry } from '../../storage/repo-manager.js';
27
9
  export interface CodebaseContext {
28
10
  projectName: string;
29
11
  stats: {
@@ -43,17 +25,66 @@ export interface CodebaseContext {
43
25
  }>;
44
26
  folderTree: string;
45
27
  }
28
+ interface RepoHandle {
29
+ id: string;
30
+ name: string;
31
+ repoPath: string;
32
+ storagePath: string;
33
+ kuzuPath: string;
34
+ indexedAt: string;
35
+ lastCommit: string;
36
+ stats?: RegistryEntry['stats'];
37
+ }
46
38
  export declare class LocalBackend {
47
- private repo;
48
- private _context;
49
- private initialized;
50
- init(cwd: string): Promise<boolean>;
39
+ private repos;
40
+ private contextCache;
41
+ private initializedRepos;
42
+ /**
43
+ * Initialize from the global registry.
44
+ * Returns true if at least one repo is available.
45
+ */
46
+ init(): Promise<boolean>;
47
+ /**
48
+ * Generate a stable repo ID from name + path.
49
+ * If names collide, append a hash of the path.
50
+ */
51
+ private repoId;
52
+ /**
53
+ * Resolve which repo to use.
54
+ * - If repoParam is given, match by name or path
55
+ * - If only 1 repo, use it
56
+ * - If 0 or multiple without param, throw with helpful message
57
+ */
58
+ resolveRepo(repoParam?: string): RepoHandle;
51
59
  private ensureInitialized;
52
- get context(): CodebaseContext | null;
53
60
  get isReady(): boolean;
61
+ /**
62
+ * Get context for a specific repo (or the single repo if only one).
63
+ */
64
+ getContext(repoId?: string): CodebaseContext | null;
65
+ /**
66
+ * Backwards-compatible getter — returns context of single repo or null.
67
+ */
68
+ get context(): CodebaseContext | null;
69
+ getRepoPath(repoId?: string): string | null;
54
70
  get repoPath(): string | null;
55
- get storagePath(): string | null;
71
+ getMeta(repoId?: string): {
72
+ lastCommit: string;
73
+ indexedAt: string;
74
+ stats?: any;
75
+ } | null;
56
76
  get meta(): any;
77
+ get storagePath(): string | null;
78
+ /**
79
+ * List all registered repos with their metadata.
80
+ */
81
+ listRepos(): Array<{
82
+ name: string;
83
+ path: string;
84
+ indexedAt: string;
85
+ lastCommit: string;
86
+ stats?: any;
87
+ }>;
57
88
  callTool(method: string, params: any): Promise<any>;
58
89
  private search;
59
90
  /**
@@ -71,3 +102,4 @@ export declare class LocalBackend {
71
102
  private analyze;
72
103
  disconnect(): Promise<void>;
73
104
  }
105
+ export {};