diff --git a/.gitignore b/.gitignore index b4e3787..d8ca068 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ *.log .cache/ .smart-coding-cache/ +config.json diff --git a/README.md b/README.md index 181631e..f2242c1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ An extensible Model Context Protocol (MCP) server that provides intelligent sema AI coding assistants work better when they can find relevant code quickly. Traditional keyword search falls short - if you ask "where do we handle authentication?" but your code uses "login" and "session", keyword search misses it. -This MCP server solves that by indexing your codebase with AI embeddings. Your AI assistant can search by meaning instead of exact keywords, finding relevant code even when the terminology differs. +This MCP server solves that by indexing your codebase with AI embeddings. Your AI assistant can search by meaning instead of exact keywords, finding relevant code even when the terminology differs. **Zero-configuration required—just run it and it works.** ![Example](example.png) @@ -43,6 +43,12 @@ Install globally via npm: npm install -g smart-coding-mcp ``` +Or run directly without installation using `npx`: + +```bash +npx smart-coding-mcp +``` + To update to the latest version: ```bash @@ -63,36 +69,98 @@ Add to your MCP configuration file. The location depends on your IDE and OS: Add the server configuration to the `mcpServers` object in your config file: -### Option 1: Specific Project (Recommended) +### Option 1: Zero-Config (Recommended) + +The simplest way to use the server is via `npx`. **No arguments needed.** The server will automatically detect your project root from the IDE's handshake protocol. + +```json +{ + "mcpServers": { + "smart-coding-mcp": { + "command": "npx", + "args": ["-y", "smart-coding-mcp"] + } + } +} +``` + +**How it works:** +1. Your IDE starts the server. +2. The server waits for the first `initialize` message. +3. It detects the `rootUri` or `workspaceFolders` sent by the IDE. +4. It indexes that folder automatically. + +**Client Compatibility Table:** + +| Client | Zero-Config Support | Notes | +| ---------------- | ------------------- | ----- | +| **VS Code** | ✅ Yes | Best experience. | +| **Cursor** | ✅ Yes | Fully supported. | +| **Claude Desktop**| ❌ No | Does not send workspace context. Use Option 2. | +| **Antigravity** | ⚠️ Partial | Depends on version. If automatic detection fails, use Option 2. | + +### Option 2: Explicit Configuration (Robust Fallback) + +If Zero-Config doesn't work (e.g., folder not created), or if you are using a client that doesn't send workspace context (like Claude Desktop or some Antigravity versions), use explicit arguments: ```json { "mcpServers": { "smart-coding-mcp": { "command": "smart-coding-mcp", - "args": ["--workspace", "/absolute/path/to/your/project"] + "args": ["--workspace", "C:/path/to/your/project"] } } } ``` -### Option 2: Multi-Project Support +> [!TIP] +> **Use Proper Paths**: Windows users should use forward slashes `/` (e.g., `C:/Projects/MyCode`) to avoid JSON escaping issues. + +#### Helper for Antigravity Users +If manual configuration feels too complex, we built a simple auto-setup command. + +1. Open your project in the terminal. +2. Run this single command: + +```bash +npx smart-coding-mcp --configure +``` + +This will automatically find your Antigravity config file and update it to point to your **current folder**. Then, simply **Reload Window** to finish. + +### Feature: Smart Shutdown +The server includes a "zombie process" protection mechanism. It monitors the standard input connection from the IDE; if the IDE closes or crashes, the server automatically shuts down to prevent resource exhaustion (memory leaks). + +### Option 3: Cross-Project Search (Advanced) + +**Note:** You do **NOT** need this if you just want to work on different projects in different windows. Option 1 already handles that automatically by launching a separate instance for each window. + +Use this option ONLY if you need to search code from *another* project while working in your current one (e.g., searching your backend API repo while working in your frontend repo). ```json { "mcpServers": { - "smart-coding-mcp-project-a": { + "smart-coding-mcp-frontend": { "command": "smart-coding-mcp", - "args": ["--workspace", "/path/to/project-a"] + "args": ["--workspace", "/path/to/frontend"] }, - "smart-coding-mcp-project-b": { + "smart-coding-mcp-backend": { "command": "smart-coding-mcp", - "args": ["--workspace", "/path/to/project-b"] + "args": ["--workspace", "/path/to/backend"] } } } ``` +### Troubleshooting & CLI + +To see all available options and environment variables, you can run the server with the `--help` flag: + +```bash +npx smart-coding-mcp --help +``` + ## Environment Variables Override configuration settings via environment variables in your MCP config: diff --git a/config.json b/config.example.json similarity index 100% rename from config.json rename to config.example.json diff --git a/features/configure.js b/features/configure.js new file mode 100644 index 0000000..fc744b5 --- /dev/null +++ b/features/configure.js @@ -0,0 +1,99 @@ +import fs from "fs/promises"; +import path from "path"; +import { saveGlobalConfig } from "../lib/config.js"; + +export class Configure { + constructor(config) { + this.config = config; + } + + async configure(newPath, settings = {}) { + const targetDir = path.resolve(newPath); + + // Validate path + try { + await fs.access(targetDir); + } catch (error) { + let message = `Access failed for path: ${targetDir}. ${error.message}`; + if (error.code === 'ENOENT') { + message = `Invalid path: ${targetDir}. Directory does not exist.`; + } else if (error.code === 'EACCES' || error.code === 'EPERM') { + message = `Permission denied: ${targetDir}. Please check your permissions.`; + } + return { success: false, message, code: error.code }; + } + + // Save settings to Encapsulated Project Config (.smart-coding-cache/config.json) + // We reuse the saveGlobalConfig function because it now targets the project-local storage + const success = await saveGlobalConfig(settings, targetDir); + + if (success) { + return { + success: true, + message: `Configuration saved to encapsulated project folder: ${path.join(targetDir, '.smart-coding-cache', 'config.json')}. Settings updated: ${JSON.stringify(settings)}` + }; + } else { + return { + success: false, + message: `Failed to save configuration to encapsulated project folder.` + }; + } + } +} + +export function getToolDefinition(config) { + return { + name: "configure_workspace", + description: "Dynamically configures the workspace path and performance settings. Updates the encapsulated configuration file in your project's .smart-coding-cache directory.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "Absolute path to the project root directory you want to index (e.g. C:/Users/Name/Project)" + }, + settings: { + type: "object", + description: "Optional performance settings", + properties: { + workerThreads: { type: "number", description: "Number of worker threads (1 for low resource usage)" }, + watchFiles: { type: "boolean", description: "Enable/disable file watching" } + } + } + }, + required: ["path"] + } + }; +} + +export async function handleToolCall(request, instance) { + if (!request?.params?.arguments?.path) { + return { + isError: true, + content: [{ type: "text", text: "Error: Missing required argument 'path'." }] + }; + } + + try { + const newPath = request.params.arguments.path; + const settings = request.params.arguments.settings || {}; + + const result = await instance.configure(newPath, settings); + + if (!result.success) { + return { + isError: true, + content: [{ type: "text", text: result.message }] + }; + } + + return { + content: [{ type: "text", text: result.message }] + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: `Configuration failed: ${error.message}` }] + }; + } +} diff --git a/features/index-codebase.js b/features/index-codebase.js index fa2360c..6ed7c76 100644 --- a/features/index-codebase.js +++ b/features/index-codebase.js @@ -19,14 +19,21 @@ export class CodebaseIndexer { this.workers = []; this.workerReady = []; this.isIndexing = false; + + // Smart Watcher State + this.changeQueue = new Set(); + this.debounceTimer = null; + this.firstChangeTimestamp = null; + this.DEBOUNCE_DELAY = 10000; // 10 seconds quiet period + this.MAX_WAIT = 60000; // 60 seconds max wait } /** * Initialize worker thread pool for parallel embedding */ async initializeWorkers() { - const numWorkers = this.config.workerThreads === "auto" - ? Math.max(1, os.cpus().length - 1) + const numWorkers = this.config.workerThreads === "auto" + ? Math.max(1, os.cpus().length - 1) : (this.config.workerThreads || 1); // Only use workers if we have more than 1 CPU @@ -40,13 +47,13 @@ export class CodebaseIndexer { } console.error(`[Indexer] Initializing ${numWorkers} worker threads...`); - + const workerPath = path.join(__dirname, "../lib/embedding-worker.js"); - + for (let i = 0; i < numWorkers; i++) { try { const worker = new Worker(workerPath, { - workerData: { + workerData: { embeddingModel: this.config.embeddingModel, verbose: this.config.verbose } @@ -54,7 +61,7 @@ export class CodebaseIndexer { const readyPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error("Worker init timeout")), 120000); - + worker.once("message", (msg) => { clearTimeout(timeout); if (msg.type === "ready") { @@ -63,7 +70,7 @@ export class CodebaseIndexer { reject(new Error(msg.error)); } }); - + worker.once("error", (err) => { clearTimeout(timeout); reject(err); @@ -148,7 +155,7 @@ export class CodebaseIndexer { const promise = new Promise((resolve, reject) => { const worker = this.workers[i]; const batchId = `batch-${i}-${Date.now()}`; - + // Timeout handler const timeout = setTimeout(() => { worker.off("message", handler); @@ -188,7 +195,7 @@ export class CodebaseIndexer { // Wait for all workers with error recovery const workerResults = await Promise.all(workerPromises.map(p => p.promise)); - + // Collect results and identify failed chunks that need retry const failedChunks = []; for (let i = 0; i < workerResults.length; i++) { @@ -215,7 +222,7 @@ export class CodebaseIndexer { */ async processChunksSingleThreaded(chunks) { const results = []; - + for (const chunk of chunks) { try { const output = await this.embedder(chunk.text, { pooling: "mean", normalize: true }); @@ -246,26 +253,26 @@ export class CodebaseIndexer { if (this.config.verbose) { console.error(`[Indexer] Processing: ${fileName}...`); } - + try { // Check file size first const stats = await fs.stat(file); - + // Skip directories if (stats.isDirectory()) { return 0; } - + if (stats.size > this.config.maxFileSize) { if (this.config.verbose) { console.error(`[Indexer] Skipped ${fileName} (too large: ${(stats.size / 1024 / 1024).toFixed(2)}MB)`); } return 0; } - + const content = await fs.readFile(file, "utf-8"); const hash = hashContent(content); - + // Skip if file hasn't changed if (this.cache.getFileHash(file) === hash) { if (this.config.verbose) { @@ -277,17 +284,17 @@ export class CodebaseIndexer { if (this.config.verbose) { console.error(`[Indexer] Indexing ${fileName}...`); } - + // Remove old chunks for this file this.cache.removeFileFromStore(file); - + const chunks = smartChunk(content, file, this.config); let addedChunks = 0; for (const chunk of chunks) { try { const output = await this.embedder(chunk.text, { pooling: "mean", normalize: true }); - + this.cache.addToStore({ file, startLine: chunk.startLine, @@ -318,10 +325,10 @@ export class CodebaseIndexer { */ async discoverFiles() { const startTime = Date.now(); - + // Build extension filter from config const extensions = new Set(this.config.fileExtensions.map(ext => `.${ext}`)); - + // Extract directory names from glob patterns in config.excludePatterns // Patterns like "**/node_modules/**" -> "node_modules" const excludeDirs = new Set(); @@ -337,10 +344,10 @@ export class CodebaseIndexer { excludeDirs.add(match2[1]); } } - + // Always exclude cache directory excludeDirs.add(".smart-coding-cache"); - + if (this.config.verbose) { console.error(`[Indexer] Using ${excludeDirs.size} exclude directories from config`); } @@ -352,7 +359,7 @@ export class CodebaseIndexer { .crawl(this.config.searchDirectory); const files = await api.withPromise(); - + console.error(`[Indexer] File discovery: ${files.length} files in ${Date.now() - startTime}ms`); return files; } @@ -367,32 +374,32 @@ export class CodebaseIndexer { // Process in parallel batches for speed const BATCH_SIZE = 500; - + for (let i = 0; i < files.length; i += BATCH_SIZE) { const batch = files.slice(i, i + BATCH_SIZE); - + const results = await Promise.all( batch.map(async (file) => { try { const stats = await fs.stat(file); - + if (stats.isDirectory()) { return null; } - + if (stats.size > this.config.maxFileSize) { skippedCount.tooLarge++; return null; } - + const content = await fs.readFile(file, "utf-8"); const hash = hashContent(content); - + if (this.cache.getFileHash(file) === hash) { skippedCount.unchanged++; return null; } - + return { file, content, hash }; } catch (error) { skippedCount.error++; @@ -426,187 +433,276 @@ export class CodebaseIndexer { } const totalStartTime = Date.now(); - console.error(`[Indexer] Starting optimized indexing in ${this.config.searchDirectory}...`); - - // Step 1: Fast file discovery with fdir - const files = await this.discoverFiles(); - - if (files.length === 0) { - console.error("[Indexer] No files found to index"); - this.sendProgress(100, 100, "No files found to index"); - return { skipped: false, filesProcessed: 0, chunksCreated: 0, message: "No files found to index" }; - } + console.error(`[Indexer] Starting optimized indexing in ${this.config.searchDirectory}...`); - // Send progress: discovery complete - this.sendProgress(5, 100, `Discovered ${files.length} files`); + // Step 1: Fast file discovery with fdir + console.error(`\n=== SMART CODING MCP: STARTING INDEXING ===`); + console.error(`[Indexer] Target Directory: ${this.config.searchDirectory}`); - // Step 1.5: Prune deleted or excluded files from cache - if (!force) { - const currentFilesSet = new Set(files); - const cachedFiles = Array.from(this.cache.fileHashes.keys()); - let prunedCount = 0; + // Step 1: Fast file discovery with fdir + const files = await this.discoverFiles(); - for (const cachedFile of cachedFiles) { - if (!currentFilesSet.has(cachedFile)) { - this.cache.removeFileFromStore(cachedFile); - this.cache.deleteFileHash(cachedFile); - prunedCount++; - } + if (files.length === 0) { + console.error("[Indexer] No files found to index"); + this.sendProgress(100, 100, "No files found to index"); + return { skipped: false, filesProcessed: 0, chunksCreated: 0, message: "No files found to index" }; } - - if (prunedCount > 0) { - if (this.config.verbose) { - console.error(`[Indexer] Pruned ${prunedCount} deleted/excluded files from index`); + + // Send progress: discovery complete + this.sendProgress(5, 100, `Discovered ${files.length} files`); + + // Step 1.5: Prune deleted or excluded files from cache + if (!force) { + const currentFilesSet = new Set(files); + const cachedFiles = Array.from(this.cache.fileHashes.keys()); + let prunedCount = 0; + + for (const cachedFile of cachedFiles) { + if (!currentFilesSet.has(cachedFile)) { + this.cache.removeFileFromStore(cachedFile); + this.cache.deleteFileHash(cachedFile); + prunedCount++; + } + } + + if (prunedCount > 0) { + if (this.config.verbose) { + console.error(`[Indexer] Pruned ${prunedCount} deleted/excluded files from index`); + } + // If we pruned files, we should save these changes even if no other files changed } - // If we pruned files, we should save these changes even if no other files changed } - } - // Step 2: Pre-filter unchanged files (early hash check) - const filesToProcess = await this.preFilterFiles(files); - - if (filesToProcess.length === 0) { - console.error("[Indexer] All files unchanged, nothing to index"); - this.sendProgress(100, 100, "All files up to date"); - await this.cache.save(); - const vectorStore = this.cache.getVectorStore(); - return { - skipped: false, - filesProcessed: 0, - chunksCreated: 0, - totalFiles: new Set(vectorStore.map(v => v.file)).size, - totalChunks: vectorStore.length, - message: "All files up to date" - }; - } + // Step 2: Pre-filter unchanged files (early hash check) + const filesToProcess = await this.preFilterFiles(files); - // Send progress: filtering complete - this.sendProgress(10, 100, `Processing ${filesToProcess.length} changed files`); + if (filesToProcess.length === 0) { + console.error("[Indexer] All files unchanged, nothing to index"); + this.sendProgress(100, 100, "All files up to date"); + await this.cache.save(); + const vectorStore = this.cache.getVectorStore(); + return { + skipped: false, + filesProcessed: 0, + chunksCreated: 0, + totalFiles: new Set(vectorStore.map(v => v.file)).size, + totalChunks: vectorStore.length, + message: "All files up to date" + }; + } - // Step 3: Determine batch size based on project size - const adaptiveBatchSize = files.length > 10000 ? 500 : - files.length > 1000 ? 200 : - this.config.batchSize || 100; + // Send progress: filtering complete + this.sendProgress(10, 100, `Processing ${filesToProcess.length} changed files`); - console.error(`[Indexer] Processing ${filesToProcess.length} files (batch size: ${adaptiveBatchSize})`); + // Step 3: Determine batch size based on project size + const adaptiveBatchSize = files.length > 10000 ? 500 : + files.length > 1000 ? 200 : + this.config.batchSize || 100; - // Step 4: Initialize worker threads (always use when multi-core available) - const useWorkers = os.cpus().length > 1; - - if (useWorkers) { - await this.initializeWorkers(); - console.error(`[Indexer] Multi-threaded mode: ${this.workers.length} workers active`); - } else { - console.error(`[Indexer] Single-threaded mode (single-core system)`); - } + console.error(`[Indexer] Processing ${filesToProcess.length} files (batch size: ${adaptiveBatchSize})`); - let totalChunks = 0; - let processedFiles = 0; - - // Step 5: Process files in adaptive batches - for (let i = 0; i < filesToProcess.length; i += adaptiveBatchSize) { - const batch = filesToProcess.slice(i, i + adaptiveBatchSize); - - // Generate all chunks for this batch - const allChunks = []; - - for (const { file, content, hash } of batch) { - // Remove old chunks for this file - this.cache.removeFileFromStore(file); - - const chunks = smartChunk(content, file, this.config); - - for (const chunk of chunks) { - allChunks.push({ - file, - text: chunk.text, - startLine: chunk.startLine, - endLine: chunk.endLine, - hash - }); - } - } + // Step 4: Initialize worker threads (always use when multi-core available) + const useWorkers = os.cpus().length > 1; - // Process chunks (with workers if available, otherwise single-threaded) - let results; - if (useWorkers && this.workers.length > 0) { - results = await this.processChunksWithWorkers(allChunks); + if (useWorkers) { + await this.initializeWorkers(); + console.error(`[Indexer] Multi-threaded mode: ${this.workers.length} workers active`); } else { - results = await this.processChunksSingleThreaded(allChunks); + console.error(`[Indexer] Single-threaded mode (single-core system)`); } - // Store successful results - const fileHashes = new Map(); - for (const result of results) { - if (result.success) { - this.cache.addToStore({ - file: result.file, - startLine: result.startLine, - endLine: result.endLine, - content: result.content, - vector: result.vector - }); - totalChunks++; + let totalChunks = 0; + let processedFiles = 0; + + // Step 5: Process files in adaptive batches + for (let i = 0; i < filesToProcess.length; i += adaptiveBatchSize) { + const batch = filesToProcess.slice(i, i + adaptiveBatchSize); + + // Generate all chunks for this batch + const allChunks = []; + + for (const { file, content, hash } of batch) { + // Remove old chunks for this file + this.cache.removeFileFromStore(file); + + const chunks = smartChunk(content, file, this.config); + + for (const chunk of chunks) { + allChunks.push({ + file, + text: chunk.text, + startLine: chunk.startLine, + endLine: chunk.endLine, + hash + }); + } + } + + // Process chunks (with workers if available, otherwise single-threaded) + let results; + if (useWorkers && this.workers.length > 0) { + results = await this.processChunksWithWorkers(allChunks); + } else { + results = await this.processChunksSingleThreaded(allChunks); } - // Track hash for each file - const chunkInfo = allChunks.find(c => c.file === result.file); - if (chunkInfo) { - fileHashes.set(result.file, chunkInfo.hash); + + // Store successful results + const fileHashes = new Map(); + for (const result of results) { + if (result.success) { + this.cache.addToStore({ + file: result.file, + startLine: result.startLine, + endLine: result.endLine, + content: result.content, + vector: result.vector + }); + totalChunks++; + } + // Track hash for each file + const chunkInfo = allChunks.find(c => c.file === result.file); + if (chunkInfo) { + fileHashes.set(result.file, chunkInfo.hash); + } } - } - // Update file hashes - for (const [file, hash] of fileHashes) { - this.cache.setFileHash(file, hash); + // Update file hashes + for (const [file, hash] of fileHashes) { + this.cache.setFileHash(file, hash); + } + + processedFiles += batch.length; + + // Progress indicator every batch + if (processedFiles % (adaptiveBatchSize * 2) === 0 || processedFiles === filesToProcess.length) { + const elapsed = ((Date.now() - totalStartTime) / 1000).toFixed(1); + const rate = (processedFiles / parseFloat(elapsed)).toFixed(0); + console.error(`[Indexer] Progress: ${processedFiles}/${filesToProcess.length} files (${rate} files/sec)`); + + // Send MCP progress notification (10-95% range for batch processing) + const progressPercent = Math.floor(10 + (processedFiles / filesToProcess.length) * 85); + this.sendProgress(progressPercent, 100, `Indexed ${processedFiles}/${filesToProcess.length} files (${rate}/sec)`); + } } - processedFiles += batch.length; - - // Progress indicator every batch - if (processedFiles % (adaptiveBatchSize * 2) === 0 || processedFiles === filesToProcess.length) { - const elapsed = ((Date.now() - totalStartTime) / 1000).toFixed(1); - const rate = (processedFiles / parseFloat(elapsed)).toFixed(0); - console.error(`[Indexer] Progress: ${processedFiles}/${filesToProcess.length} files (${rate} files/sec)`); - - // Send MCP progress notification (10-95% range for batch processing) - const progressPercent = Math.floor(10 + (processedFiles / filesToProcess.length) * 85); - this.sendProgress(progressPercent, 100, `Indexed ${processedFiles}/${filesToProcess.length} files (${rate}/sec)`); + // Cleanup workers + if (useWorkers) { + this.terminateWorkers(); } + + const totalTime = ((Date.now() - totalStartTime) / 1000).toFixed(1); + console.error(`[Indexer] Complete: ${totalChunks} chunks from ${filesToProcess.length} files in ${totalTime}s`); + + // Send completion progress + this.sendProgress(100, 100, `Complete: ${totalChunks} chunks from ${filesToProcess.length} files in ${totalTime}s`); + + console.error(`=== SMART CODING MCP: INDEXING COMPLETE ===`); + console.error(`[Indexer] Processed ${filesToProcess.length} files in ${totalTime}s`); + console.error(`[Indexer] You can now search your codebase!`); + console.error(`============================================\n`); + + await this.cache.save(); + + const vectorStore = this.cache.getVectorStore(); + return { + skipped: false, + filesProcessed: filesToProcess.length, + chunksCreated: totalChunks, + totalFiles: new Set(vectorStore.map(v => v.file)).size, + totalChunks: vectorStore.length, + duration: totalTime, + message: `Indexed ${filesToProcess.length} files (${totalChunks} chunks) in ${totalTime}s` + }; + } finally { + this.isIndexing = false; } + } - // Cleanup workers - if (useWorkers) { - this.terminateWorkers(); + async processChangeQueue() { + if (this.changeQueue.size === 0) return; + + if (this.isIndexing) { + console.error("[Watcher] Indexing in progress, deferring change queue processing"); + // Re-schedule after a delay + this.debounceTimer = setTimeout(() => { + void this.processChangeQueue().catch(err => { + console.error(`[Watcher] Background queue processing error: ${err.message}`); + }); + }, this.DEBOUNCE_DELAY); + return; } - const totalTime = ((Date.now() - totalStartTime) / 1000).toFixed(1); - console.error(`[Indexer] Complete: ${totalChunks} chunks from ${filesToProcess.length} files in ${totalTime}s`); - - // Send completion progress - this.sendProgress(100, 100, `Complete: ${totalChunks} chunks from ${filesToProcess.length} files in ${totalTime}s`); - - await this.cache.save(); - - const vectorStore = this.cache.getVectorStore(); - return { - skipped: false, - filesProcessed: filesToProcess.length, - chunksCreated: totalChunks, - totalFiles: new Set(vectorStore.map(v => v.file)).size, - totalChunks: vectorStore.length, - duration: totalTime, - message: `Indexed ${filesToProcess.length} files (${totalChunks} chunks) in ${totalTime}s` - }; + this.isIndexing = true; + try { + // Reset timers + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = null; + this.firstChangeTimestamp = null; + + const filesToProcess = Array.from(this.changeQueue); + this.changeQueue.clear(); + + console.error(`\n=== SMART CODING MCP: PROCESS PENDING CHANGES ===`); + console.error(`[Watcher] Processing ${filesToProcess.length} pending file changes...`); + + let processedCount = 0; + for (const file of filesToProcess) { + // Check if file still exists (it might have been deleted while queued) + try { + await fs.access(file); + const chunks = await this.indexFile(file); + if (chunks > 0) processedCount++; + } catch { + // File deleted, remove from index + this.cache.removeFileFromStore(file); + this.cache.deleteFileHash(file); + } + } + + await this.cache.save(); + console.error(`[Watcher] Updated ${processedCount} files in index`); + console.error(`=================================================\n`); } finally { this.isIndexing = false; } } + queueFileChange(filePath) { + const fullPath = path.join(this.config.searchDirectory, filePath); + this.changeQueue.add(fullPath); + + // Initialize max wait timer if this is the first change in the batch + if (!this.firstChangeTimestamp) { + this.firstChangeTimestamp = Date.now(); + } + + // Check if we've waited too long (Max Wait) + if (Date.now() - this.firstChangeTimestamp > this.MAX_WAIT) { + console.error(`[Watcher] Max wait time reached (${this.MAX_WAIT}ms), forcing update...`); + // Don't await - let it run in background, but the guard will prevent races + void this.processChangeQueue().catch(err => { + console.error(`[Watcher] Background queue processing error: ${err.message}`); + }); + return; + } + + // Reset Debounce Timer (Wait for silence) + if (this.debounceTimer) clearTimeout(this.debounceTimer); + + console.error(`[Watcher] Change detected: ${path.basename(filePath)} (queued, waiting for ${this.DEBOUNCE_DELAY / 1000}s silence)`); + + this.debounceTimer = setTimeout(() => { + void this.processChangeQueue().catch(err => { + console.error(`[Watcher] Background queue processing error: ${err.message}`); + }); + }, this.DEBOUNCE_DELAY); + } + setupFileWatcher() { if (!this.config.watchFiles) return; const pattern = this.config.fileExtensions.map(ext => `**/*.${ext}`); - + this.watcher = chokidar.watch(pattern, { cwd: this.config.searchDirectory, ignored: this.config.excludePatterns, @@ -615,27 +711,23 @@ export class CodebaseIndexer { }); this.watcher - .on("add", async (filePath) => { - const fullPath = path.join(this.config.searchDirectory, filePath); - console.error(`[Indexer] New file detected: ${filePath}`); - await this.indexFile(fullPath); - await this.cache.save(); - }) - .on("change", async (filePath) => { - const fullPath = path.join(this.config.searchDirectory, filePath); - console.error(`[Indexer] File changed: ${filePath}`); - await this.indexFile(fullPath); - await this.cache.save(); - }) - .on("unlink", (filePath) => { + .on("add", (filePath) => this.queueFileChange(filePath)) + .on("change", (filePath) => this.queueFileChange(filePath)) + .on("unlink", async (filePath) => { const fullPath = path.join(this.config.searchDirectory, filePath); - console.error(`[Indexer] File deleted: ${filePath}`); + console.error(`[Watcher] File deleted: ${filePath} (removing immediately)`); this.cache.removeFileFromStore(fullPath); this.cache.deleteFileHash(fullPath); - this.cache.save(); + try { + await this.cache.save(); + } catch (err) { + console.error(`[Watcher] Failed to save cache after deletion: ${err.message}`); + } + // Also remove from queue if it was pending + this.changeQueue.delete(fullPath); }); - console.error("[Indexer] File watcher enabled for incremental indexing"); + console.error(`[Watcher] Smart Watcher enabled (Lazy Update Mode - ${this.DEBOUNCE_DELAY / 1000}s debounce)`); } } @@ -668,7 +760,7 @@ export function getToolDefinition() { export async function handleToolCall(request, indexer) { const force = request.params.arguments?.force || false; const result = await indexer.indexAll(force); - + // Handle case when indexing was skipped due to concurrent request if (result?.skipped) { return { @@ -678,7 +770,7 @@ export async function handleToolCall(request, indexer) { }] }; } - + // Get current stats from cache const vectorStore = indexer.cache.getVectorStore(); const stats = { @@ -687,17 +779,17 @@ export async function handleToolCall(request, indexer) { filesProcessed: result?.filesProcessed ?? 0, chunksCreated: result?.chunksCreated ?? 0 }; - - let message = result?.message + + let message = result?.message ? `Codebase reindexed successfully.\n\n${result.message}` : `Codebase reindexed successfully.`; - + message += `\n\nStatistics:\n- Total files in index: ${stats.totalFiles}\n- Total code chunks: ${stats.totalChunks}`; - + if (stats.filesProcessed > 0) { message += `\n- Files processed this run: ${stats.filesProcessed}\n- Chunks created this run: ${stats.chunksCreated}`; } - + return { content: [{ type: "text", diff --git a/index.js b/index.js index 2495ed3..5792be2 100755 --- a/index.js +++ b/index.js @@ -1,16 +1,21 @@ #!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +// ... imports +import { CallToolRequestSchema, ListToolsRequestSchema, InitializeRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { pipeline } from "@xenova/transformers"; import fs from "fs/promises"; +import path from "path"; +import os from "os"; import { createRequire } from "module"; +import { fileURLToPath } from "url"; // Import package.json for version const require = createRequire(import.meta.url); const packageJson = require("./package.json"); import { loadConfig } from "./lib/config.js"; +import { configureAntigravity } from "./lib/ide-setup.js"; import { EmbeddingsCache } from "./lib/cache.js"; import { CodebaseIndexer } from "./features/index-codebase.js"; import { HybridSearch } from "./features/hybrid-search.js"; @@ -18,35 +23,56 @@ import { HybridSearch } from "./features/hybrid-search.js"; import * as IndexCodebaseFeature from "./features/index-codebase.js"; import * as HybridSearchFeature from "./features/hybrid-search.js"; import * as ClearCacheFeature from "./features/clear-cache.js"; +import * as ConfigureFeature from "./features/configure.js"; -// Parse workspace from command line arguments +// Parse arguments const args = process.argv.slice(2); + +// Handle help flag +if (args.includes('--help') || args.includes('-h')) { + console.log(` +Smart Coding MCP v${packageJson.version} +Usage: npx smart-coding-mcp [options] + +Options: + --workspace Set the active workspace directory (default: current directory) + --configure Automatically update Antigravity configuration for current directory + --help, -h Show this help message + +Environment Variables: + SMART_CODING_VERBOSE=true Enable verbose logging + SMART_CODING_WATCH_FILES=true Enable file watching + `); + process.exit(0); +} + +// Handle Configuration Mode +if (args.includes('--configure') || args.includes('--setup')) { + configureAntigravity(); + process.exit(0); +} + const workspaceIndex = args.findIndex(arg => arg.startsWith('--workspace')); -let workspaceDir = null; +let workspaceDir = process.cwd(); if (workspaceIndex !== -1) { const arg = args[workspaceIndex]; let rawWorkspace = null; - + if (arg.includes('=')) { - rawWorkspace = arg.split('=')[1]; + rawWorkspace = arg.substring(arg.indexOf('=') + 1); } else if (workspaceIndex + 1 < args.length) { rawWorkspace = args[workspaceIndex + 1]; } - - // Check if IDE variable wasn't expanded (contains ${}) - if (rawWorkspace && rawWorkspace.includes('${')) { - console.error(`[Server] IDE variable not expanded: ${rawWorkspace}, using current directory`); - workspaceDir = process.cwd(); - } else if (rawWorkspace) { - workspaceDir = rawWorkspace; - } - - if (workspaceDir) { - console.error(`[Server] Workspace mode: ${workspaceDir}`); + + // Check if IDE variable was expanded, otherwise use provided path + if (rawWorkspace && !rawWorkspace.includes('${')) { + workspaceDir = path.resolve(process.cwd(), rawWorkspace); } } +console.error(`[Server] Active Workspace: ${workspaceDir}`); + // Global state let embedder = null; let cache = null; @@ -54,7 +80,23 @@ let indexer = null; let hybridSearch = null; let config = null; -// Feature registry - ordered by priority (semantic_search first as primary tool) +// Server instance (moved up for global access) +const server = new Server( + { + name: "smart-coding-mcp", + version: packageJson.version + }, + { + capabilities: { + tools: {} + } + } +); + +// Init promise to coordinate startup +let readyPromise = null; + +// Feature registry const features = [ { module: HybridSearchFeature, @@ -70,20 +112,28 @@ const features = [ module: ClearCacheFeature, instance: null, handler: ClearCacheFeature.handleToolCall + }, + { + module: ConfigureFeature, + instance: null, + handler: ConfigureFeature.handleToolCall } ]; // Initialize application -async function initialize() { +async function initialize(rootPath) { + console.error(`[Server] Initializing workspace: ${rootPath}`); + // Load configuration with workspace support - config = await loadConfig(workspaceDir); - + config = await loadConfig(rootPath); + // Ensure search directory exists try { await fs.access(config.searchDirectory); } catch { console.error(`[Server] Error: Search directory "${config.searchDirectory}" does not exist`); - process.exit(1); + // Don't exit process, just throw to handle gracefully + throw new Error(`Search directory "${config.searchDirectory}" does not exist`); } // Load AI model @@ -98,41 +148,85 @@ async function initialize() { indexer = new CodebaseIndexer(embedder, cache, config, server); hybridSearch = new HybridSearch(embedder, cache, config); const cacheClearer = new ClearCacheFeature.CacheClearer(embedder, cache, config, indexer); + const configurator = new ConfigureFeature.Configure(config); - // Store feature instances (matches features array order) + // Store feature instances features[0].instance = hybridSearch; features[1].instance = indexer; features[2].instance = cacheClearer; + features[3].instance = configurator; // Start indexing in background (non-blocking) console.error("[Server] Starting background indexing..."); indexer.indexAll().then(() => { - // Only start file watcher if explicitly enabled in config if (config.watchFiles) { indexer.setupFileWatcher(); } }).catch(err => { console.error("[Server] Background indexing error:", err.message); }); + + return true; } -// Setup MCP server -const server = new Server( - { - name: "smart-coding-mcp", - version: packageJson.version - }, - { - capabilities: { - tools: {} - } +// Handle Initialize Request (Handshake) +server.setRequestHandler(InitializeRequestSchema, async (request) => { + // If not already initializing (from CLI args), try to init from protocol + if (!readyPromise) { + let rootPath = process.cwd(); // Fallback + + // Strategy 1: Check rootUri + if (request.params.rootUri) { + try { + const uri = request.params.rootUri; + if (uri.startsWith('file://')) { + rootPath = fileURLToPath(uri); + } + } catch (err) { + console.error(`[Server] Failed to parse rootUri: ${err.message}`); + } + } + // Strategy 2: Check workspaceFolders (Array) + else if (request.params.workspaceFolders && request.params.workspaceFolders.length > 0) { + const firstFolder = request.params.workspaceFolders[0]; + try { + if (firstFolder.uri.startsWith('file://')) { + rootPath = fileURLToPath(firstFolder.uri); + } + } catch (err) { + console.error(`[Server] Failed to parse workspaceFolder: ${err.message}`); + } + } + + console.error(`[Server] Auto-detected workspace: ${rootPath}`); + + readyPromise = initialize(rootPath).catch(err => { + console.error(`[Server] Critical initialization failure: ${err.message}`); + process.exit(1); + }); } -); -// Register tools from all features + return { + protocolVersion: "2024-11-05", + capabilities: { + tools: {} + }, + serverInfo: { + name: "smart-coding-mcp", + version: packageJson.version + } + }; +}); + +// Register tools server.setRequestHandler(ListToolsRequestSchema, async () => { + // Wait for init + if (readyPromise) await readyPromise; + const tools = []; - + // Guard against uninitialized state + if (!config) return { tools: [] }; + for (const feature of features) { const toolDef = feature.module.getToolDefinition(config); tools.push(toolDef); @@ -143,9 +237,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (readyPromise) await readyPromise; + for (const feature of features) { + // If config is not loaded yet, we can't get definitions, but we shouldn't be here if listTools worked? + // Safety check + if (!config) continue; + const toolDef = feature.module.getToolDefinition(config); - + if (request.params.name === toolDef.name) { return await feature.handler(request, feature.instance); } @@ -159,32 +259,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; }); +// Main entry point // Main entry point async function main() { - await initialize(); - + // If workspace was explicitly provided via CLI, start initializing immediately + if (workspaceIndex !== -1) { + console.error(`[Server] CLI argument detected. Starting immediate initialization for: ${workspaceDir}`); + readyPromise = initialize(workspaceDir).catch(err => { + console.error(`[Server] Critical initialization failure: ${err.message}`); + process.exit(1); + }); + } else { + // Zero-Config Mode: Defer initialization until client handshake + console.error(`[Server] No CLI workspace arg. Waiting for MCP 'initialize' handshake...`); + } + const transport = new StdioServerTransport(); await server.connect(transport); - + console.error("[Server] Smart Coding MCP server ready!"); + + // cleanup on exit + process.stdin.resume(); // Ensure it's flowing + process.stdin.on('close', () => { + console.error("[Server] stdin closed, shutting down..."); + process.exit(0); + }); + + // Also handle writing to closed stdout causing errors + process.stdout.on('error', (err) => { + if (err.code === 'EPIPE') { + process.exit(0); + } + }); } // Graceful shutdown process.on('SIGINT', async () => { console.error("\n[Server] Shutting down gracefully..."); - + // Stop file watcher if (indexer && indexer.watcher) { await indexer.watcher.close(); console.error("[Server] File watcher stopped"); } - + // Save cache if (cache) { await cache.save(); console.error("[Server] Cache saved"); } - + console.error("[Server] Goodbye!"); process.exit(0); }); diff --git a/lib/config.js b/lib/config.js index 6faee87..5fd77f1 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,44 +1,27 @@ import fs from "fs/promises"; import path from "path"; -import { fileURLToPath } from "url"; +import { execFile } from "child_process"; import { ProjectDetector } from "./project-detector.js"; const DEFAULT_CONFIG = { searchDirectory: ".", fileExtensions: [ - // JavaScript/TypeScript "js", "ts", "jsx", "tsx", "mjs", "cjs", - // Styles "css", "scss", "sass", "less", "styl", - // Markup "html", "htm", "xml", "svg", - // Python "py", "pyw", "pyx", - // Java/Kotlin/Scala "java", "kt", "kts", "scala", - // C/C++ "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", - // C# "cs", "csx", - // Go "go", - // Rust "rs", - // Ruby "rb", "rake", - // PHP "php", "phtml", - // Swift "swift", - // Shell scripts "sh", "bash", "zsh", "fish", - // Config & Data "json", "yaml", "yml", "toml", "ini", "env", - // Documentation "md", "mdx", "txt", "rst", - // Database "sql", - // Other "r", "R", "lua", "vim", "pl", "pm" ], excludePatterns: [ @@ -52,16 +35,16 @@ const DEFAULT_CONFIG = { "**/vendor/**", "**/.smart-coding-cache/**" ], - chunkSize: 25, // Lines per chunk (larger = fewer embeddings = faster indexing) - chunkOverlap: 5, // Overlap between chunks for context continuity + chunkSize: 25, + chunkOverlap: 5, batchSize: 100, - maxFileSize: 1048576, // 1MB - skip files larger than this + maxFileSize: 1048576, maxResults: 5, enableCache: true, cacheDirectory: "./.smart-coding-cache", watchFiles: false, verbose: false, - workerThreads: "auto", // "auto" = CPU cores - 1, or set a number + workerThreads: "auto", embeddingModel: "Xenova/all-MiniLM-L6-v2", semanticWeight: 0.7, exactMatchBoost: 1.5, @@ -71,177 +54,161 @@ const DEFAULT_CONFIG = { let config = { ...DEFAULT_CONFIG }; export async function loadConfig(workspaceDir = null) { + // 1. Determine Active Workspace (Default to CWD) + const activeDir = workspaceDir ? path.resolve(workspaceDir) : process.cwd(); + + // 2. Local Encapsulation Directory + const localDir = path.join(activeDir, ".smart-coding-cache"); + const localConfigPath = path.join(localDir, "config.json"); + + // Ensure the encapsulated folder exists + await fs.mkdir(localDir, { recursive: true }); + await ensureHidden(localDir); + + // 3. Load Project-Local Config from .smart-coding-cache/config.json + let userConfig = {}; + let configData; try { - // Determine the base directory for configuration - let baseDir; - let configPath; - - if (workspaceDir) { - // Workspace mode: load config from workspace root - baseDir = path.resolve(workspaceDir); - configPath = path.join(baseDir, "config.json"); - console.error(`[Config] Workspace mode: ${baseDir}`); - } else { - // Server mode: load config from server directory - const scriptDir = path.dirname(fileURLToPath(import.meta.url)); - baseDir = path.resolve(scriptDir, '..'); - configPath = path.join(baseDir, "config.json"); - } - - let userConfig = {}; - try { - const configData = await fs.readFile(configPath, "utf-8"); - userConfig = JSON.parse(configData); - } catch (configError) { - if (workspaceDir) { - console.error(`[Config] No config.json in workspace, using defaults`); - } else { - console.error(`[Config] No config.json found: ${configError.message}`); - } - } - - config = { ...DEFAULT_CONFIG, ...userConfig }; - - // Set workspace-specific directories - if (workspaceDir) { - config.searchDirectory = baseDir; - config.cacheDirectory = path.join(baseDir, ".smart-coding-cache"); - } else { - config.searchDirectory = path.resolve(baseDir, config.searchDirectory); - config.cacheDirectory = path.resolve(baseDir, config.cacheDirectory); - } - - // Smart project detection - if (config.smartIndexing !== false) { - const detector = new ProjectDetector(config.searchDirectory); - const detectedTypes = await detector.detectProjectTypes(); - - if (detectedTypes.length > 0) { - const smartPatterns = detector.getSmartIgnorePatterns(); - - // Merge smart patterns with user patterns (user patterns take precedence) - const userPatterns = userConfig.excludePatterns || []; - config.excludePatterns = [ - ...smartPatterns, - ...userPatterns - ]; - - console.error(`[Config] Smart indexing: ${detectedTypes.join(', ')}`); - console.error(`[Config] Applied ${smartPatterns.length} smart ignore patterns`); - } else { - console.error("[Config] No project markers detected, using default patterns"); - } - } - - console.error("[Config] Loaded configuration from config.json"); - } catch (error) { - console.error("[Config] Using default configuration (config.json not found or invalid)"); - console.error(`[Config] Error: ${error.message}`); - } - - // Apply environment variable overrides (prefix: SMART_CODING_) with validation - if (process.env.SMART_CODING_VERBOSE !== undefined) { - const value = process.env.SMART_CODING_VERBOSE; - if (value === 'true' || value === 'false') { - config.verbose = value === 'true'; - } - } - - if (process.env.SMART_CODING_BATCH_SIZE !== undefined) { - const value = parseInt(process.env.SMART_CODING_BATCH_SIZE, 10); - if (!isNaN(value) && value > 0 && value <= 1000) { - config.batchSize = value; + configData = await fs.readFile(localConfigPath, "utf-8"); + } catch (readError) { + if (readError.code === 'ENOENT') { + // If config doesn't exist, create it with defaults (Zero-Touch setup) + await fs.writeFile(localConfigPath, JSON.stringify(DEFAULT_CONFIG, null, 2), "utf-8"); + console.error(`[Config] Created new encapsulated configuration: ${localConfigPath}`); + configData = JSON.stringify(DEFAULT_CONFIG); } else { - console.error(`[Config] Invalid SMART_CODING_BATCH_SIZE: ${process.env.SMART_CODING_BATCH_SIZE}, using default`); + throw readError; } } - - if (process.env.SMART_CODING_MAX_FILE_SIZE !== undefined) { - const value = parseInt(process.env.SMART_CODING_MAX_FILE_SIZE, 10); - if (!isNaN(value) && value > 0) { - config.maxFileSize = value; - } else { - console.error(`[Config] Invalid SMART_CODING_MAX_FILE_SIZE: ${process.env.SMART_CODING_MAX_FILE_SIZE}, using default`); - } - } - - if (process.env.SMART_CODING_CHUNK_SIZE !== undefined) { - const value = parseInt(process.env.SMART_CODING_CHUNK_SIZE, 10); - if (!isNaN(value) && value > 0 && value <= 100) { - config.chunkSize = value; - } else { - console.error(`[Config] Invalid SMART_CODING_CHUNK_SIZE: ${process.env.SMART_CODING_CHUNK_SIZE}, using default`); - } - } - - if (process.env.SMART_CODING_MAX_RESULTS !== undefined) { - const value = parseInt(process.env.SMART_CODING_MAX_RESULTS, 10); - if (!isNaN(value) && value > 0 && value <= 100) { - config.maxResults = value; - } else { - console.error(`[Config] Invalid SMART_CODING_MAX_RESULTS: ${process.env.SMART_CODING_MAX_RESULTS}, using default`); - } - } - - if (process.env.SMART_CODING_SMART_INDEXING !== undefined) { - const value = process.env.SMART_CODING_SMART_INDEXING; - if (value === 'true' || value === 'false') { - config.smartIndexing = value === 'true'; - } - } - - if (process.env.SMART_CODING_WATCH_FILES !== undefined) { - const value = process.env.SMART_CODING_WATCH_FILES; - if (value === 'true' || value === 'false') { - config.watchFiles = value === 'true'; - } - } - - if (process.env.SMART_CODING_SEMANTIC_WEIGHT !== undefined) { - const value = parseFloat(process.env.SMART_CODING_SEMANTIC_WEIGHT); - if (!isNaN(value) && value >= 0 && value <= 1) { - config.semanticWeight = value; - } else { - console.error(`[Config] Invalid SMART_CODING_SEMANTIC_WEIGHT: ${process.env.SMART_CODING_SEMANTIC_WEIGHT}, using default (must be 0-1)`); - } + + try { + userConfig = JSON.parse(configData); + console.error(`[Config] Using encapsulated configuration: ${localConfigPath}`); + } catch (parseError) { + console.error(`[Config] Malformed JSON in ${localConfigPath}: ${parseError.message}`); + throw parseError; // Fail fast on syntax errors } - - if (process.env.SMART_CODING_EXACT_MATCH_BOOST !== undefined) { - const value = parseFloat(process.env.SMART_CODING_EXACT_MATCH_BOOST); - if (!isNaN(value) && value >= 0) { - config.exactMatchBoost = value; + + // 4. Merge defaults with user overrides + config = { ...DEFAULT_CONFIG, ...userConfig }; + + // 5. Force Encapsulated Paths + config.searchDirectory = activeDir; + config.cacheDirectory = localDir; + + // 6. Smart project detection + if (config.smartIndexing !== false) { + const detector = new ProjectDetector(config.searchDirectory); + const detectedTypes = await detector.detectProjectTypes(); + + if (detectedTypes.length > 0) { + const smartPatterns = detector.getSmartIgnorePatterns(); + const existingExcludes = config.excludePatterns || []; + config.excludePatterns = [...new Set([...smartPatterns, ...existingExcludes])]; + console.error(`[Config] Project type detected: ${detectedTypes.join(', ')}`); + console.error(`[Config] Smart ignore rules applied: ${smartPatterns.length} patterns`); } else { - console.error(`[Config] Invalid SMART_CODING_EXACT_MATCH_BOOST: ${process.env.SMART_CODING_EXACT_MATCH_BOOST}, using default`); + console.error(`[Config] No specific project type detected. Using generic ignore rules.`); } } - - if (process.env.SMART_CODING_EMBEDDING_MODEL !== undefined) { - const value = process.env.SMART_CODING_EMBEDDING_MODEL.trim(); - if (value.length > 0) { - config.embeddingModel = value; - console.error(`[Config] Using custom embedding model: ${value}`); + + console.error(`[Config] Active workspace resolved: ${activeDir}`); + console.error(`[Config] Persistence folder: ${localDir}`); + + applyEnvOverrides(config); + return config; +} + +/** + * Saves configuration to the encapsulated project folder + * @param {Object} updates - Settings to update + * @param {string|null} workspaceDir - Optional target directory (defaults to active workspace) + */ +export async function saveGlobalConfig(updates, workspaceDir = null) { + try { + const activeDir = workspaceDir ? path.resolve(workspaceDir) : path.resolve(config.searchDirectory || process.cwd()); + const localDir = path.join(activeDir, ".smart-coding-cache"); + const localConfigPath = path.join(localDir, "config.json"); + + await fs.mkdir(localDir, { recursive: true }); + await ensureHidden(localDir); + + let current = {}; + try { + const data = await fs.readFile(localConfigPath, "utf-8"); + try { + current = JSON.parse(data); + } catch (parseError) { + console.error(`[Config] Failed to parse existing config in ${localConfigPath}: ${parseError.message}`); + throw parseError; // Abort save to prevent overwriting + } + } catch (readError) { + if (readError.code !== 'ENOENT') { + throw readError; // Re-throw permission errors, etc. + } + // ENOENT is expected for new configs } + + const updated = { ...current, ...updates }; + await fs.writeFile(localConfigPath, JSON.stringify(updated, null, 2), "utf-8"); + return true; + } catch (err) { + console.error(`[Config] Failed to save encapsulated config: ${err.message}`); + return false; } - - if (process.env.SMART_CODING_WORKER_THREADS !== undefined) { - const value = process.env.SMART_CODING_WORKER_THREADS.trim().toLowerCase(); - if (value === 'auto') { - config.workerThreads = 'auto'; - } else { - const numValue = parseInt(value, 10); - if (!isNaN(numValue) && numValue >= 1 && numValue <= 32) { - config.workerThreads = numValue; - } else { - console.error(`[Config] Invalid SMART_CODING_WORKER_THREADS: ${value}, using default (must be 'auto' or 1-32)`); +} + +function applyEnvOverrides(config) { + const envMap = { + SMART_CODING_VERBOSE: 'verbose', + SMART_CODING_BATCH_SIZE: 'batchSize', + SMART_CODING_MAX_FILE_SIZE: 'maxFileSize', + SMART_CODING_CHUNK_SIZE: 'chunkSize', + SMART_CODING_MAX_RESULTS: 'maxResults', + SMART_CODING_WATCH_FILES: 'watchFiles', + SMART_CODING_SEMANTIC_WEIGHT: 'semanticWeight', + SMART_CODING_EMBEDDING_MODEL: 'embeddingModel', + SMART_CODING_WORKER_THREADS: 'workerThreads' + }; + + for (const [env, key] of Object.entries(envMap)) { + if (process.env[env] !== undefined) { + const val = process.env[env]; + if (val === 'true' || val === 'false') config[key] = val === 'true'; + else { + // Strict numeric validation + const trimmed = val.trim(); + if (trimmed.length > 0) { + const num = Number(trimmed); + if (Number.isFinite(num)) { + config[key] = num; + continue; + } + } + config[key] = val; } } } - - return config; } -export function getConfig() { - return config; +/** + * Ensures a directory is hidden on Windows + */ +async function ensureHidden(dirPath) { + if (process.platform === 'win32') { + try { + const normalizedPath = path.normalize(dirPath); + await new Promise((resolve, reject) => { + execFile('attrib', ['+h', normalizedPath], (error) => { + if (error) reject(error); + else resolve(); + }); + }); + } catch (err) { + console.error(`[Config] Failed to set hidden attribute: ${err.message}`); + } + } } +export function getConfig() { return config; } export { DEFAULT_CONFIG }; diff --git a/lib/ide-setup.js b/lib/ide-setup.js new file mode 100644 index 0000000..5897f96 --- /dev/null +++ b/lib/ide-setup.js @@ -0,0 +1,69 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export function configureAntigravity() { + // Platform specific paths + const CONFIG_PATH = path.join(os.homedir(), '.gemini/antigravity/mcp_config.json'); + + const CURRENT_DIR = process.cwd(); + + // Normalize path for JSON (forward slashes) + const normalizePath = (p) => p.split(path.sep).join('/'); + + console.log(`[Config] Updating Antigravity MCP config...`); + console.log(`[Config] Target: ${CONFIG_PATH}`); + console.log(`[Config] New Workspace: ${CURRENT_DIR}`); + + try { + if (!fs.existsSync(CONFIG_PATH)) { + console.error(`[Error] Config file not found at ${CONFIG_PATH}`); + console.error(`[Tip] Make sure you have opened Antigravity at least once.`); + return false; + } + + const content = fs.readFileSync(CONFIG_PATH, 'utf8'); + let config; + try { + config = JSON.parse(content); + } catch (e) { + console.error(`[Error] Failed to parse config JSON: ${e.message}`); + return false; + } + + if (!config.mcpServers || !config.mcpServers['smart-coding-mcp']) { + console.error(`[Error] 'smart-coding-mcp' entry not found in config.`); + console.error(`[Fix] Please add the server to your config first (see README).`); + return false; + } + + // Preserve existing command/env, just update args + const server = config.mcpServers['smart-coding-mcp']; + + // Ensure args is an array + const currentArgs = Array.isArray(server.args) ? server.args : []; + const newArgs = [...currentArgs]; + + // Remove existing workspace args if any + const workspaceIdx = newArgs.indexOf("--workspace"); + if (workspaceIdx !== -1) { + // Only remove value if it looks like a value (not another flag) + const hasValue = workspaceIdx + 1 < newArgs.length && !newArgs[workspaceIdx + 1].startsWith('-'); + newArgs.splice(workspaceIdx, hasValue ? 2 : 1); + } + + // Append new workspace + newArgs.push("--workspace", normalizePath(CURRENT_DIR)); + + server.args = newArgs; + + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); + console.log(`[Success] Updated config to index: ${normalizePath(CURRENT_DIR)}`); + console.log(`[Action] Please RELOAD your Antigravity window now.`); + return true; + + } catch (err) { + console.error(`[Error] Failed to update config: ${err.message}`); + return false; + } +} diff --git a/package-lock.json b/package-lock.json index b86b35f..35ced17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "smart-coding-mcp", - "version": "1.3.0", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smart-coding-mcp", - "version": "1.3.0", + "version": "1.3.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", @@ -1886,7 +1886,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2758,7 +2757,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3669,7 +3667,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3951,7 +3948,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }