# Directory Structure ``` ├── .gitignore ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── index.ts │ └── neovim.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ # Build output build/ dist/ # Environment variables .env # IDE and OS files .DS_Store .vscode/ .idea/ # Logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Neovim MCP Server Connect Claude Desktop (or any Model Context Protocol client) to Neovim using MCP and the official neovim/node-client JavaScript library. This server leverages Vim's native text editing commands and workflows, which Claude already understands, to create a lightweight code or general purpose AI text assistance layer. <a href="https://glama.ai/mcp/servers/s0fywdwp87"><img width="380" height="200" src="https://glama.ai/mcp/servers/s0fywdwp87/badge" alt="mcp-neovim-server MCP server" /></a> ## Features - Connects to your nvim instance if you expose a socket file, for example `--listen /tmp/nvim`, when starting nvim - Views your current buffers and manages buffer switching - Gets cursor location, mode, file name, marks, registers, and visual selections - Runs vim commands and optionally shell commands through vim - Can make edits using insert, replace, or replaceAll modes - Search and replace functionality with regex support - Project-wide grep search with quickfix integration - Comprehensive window management - Health monitoring and connection diagnostics ## API ### Resources - `nvim://session`: Current neovim text editor session - `nvim://buffers`: List of all open buffers in the current Neovim session with metadata including modified status, syntax, and window IDs ### Tools #### Core Tools - **vim_buffer** - Get buffer contents with line numbers (supports filename parameter) - Input `filename` (string, optional) - Get specific buffer by filename - Returns numbered lines with buffer content - **vim_command** - Send a command to VIM for navigation, spot editing, and line deletion - Input `command` (string) - Runs vim commands with `nvim.replaceTermcodes`. Multiple commands work with newlines - Shell commands supported with `!` prefix when `ALLOW_SHELL_COMMANDS=true` - On error, `'nvim:errmsg'` contents are returned - **vim_status** - Get comprehensive Neovim status - Returns cursor position, mode, filename, visual selection with enhanced detection, window layout, current tab, marks, registers, working directory, LSP client info, and plugin detection - Enhanced visual selection reporting: detects visual mode type (character/line/block), provides accurate selection text, start/end positions, and last visual selection marks - **vim_edit** - Edit lines using insert, replace, or replaceAll modes - Input `startLine` (number), `mode` (`"insert"` | `"replace"` | `"replaceAll"`), `lines` (string) - insert: insert lines at startLine - replace: replace lines starting at startLine - replaceAll: replace entire buffer contents - **vim_window** - Manipulate Neovim windows (split, vsplit, close, navigate) - Input `command` (string: "split", "vsplit", "only", "close", "wincmd h/j/k/l") - **vim_mark** - Set named marks at specific positions - Input `mark` (string: a-z), `line` (number), `column` (number) - **vim_register** - Set content of registers - Input `register` (string: a-z or "), `content` (string) - **vim_visual** - Create visual mode selections - Input `startLine` (number), `startColumn` (number), `endLine` (number), `endColumn` (number) #### Enhanced Buffer Management - **vim_buffer_switch** - Switch between buffers by name or number - Input `identifier` (string | number) - Buffer name or number - **vim_buffer_save** - Save current buffer or save to specific filename - Input `filename` (string, optional) - Save to specific file - **vim_file_open** - Open files into new buffers - Input `filename` (string) - File to open #### Search and Replace - **vim_search** - Search within current buffer with regex support - Input `pattern` (string), `ignoreCase` (boolean, optional), `wholeWord` (boolean, optional) - **vim_search_replace** - Find and replace with advanced options - Input `pattern` (string), `replacement` (string), `global` (boolean, optional), `ignoreCase` (boolean, optional), `confirm` (boolean, optional) - **vim_grep** - Project-wide search using vimgrep with quickfix list - Input `pattern` (string), `filePattern` (string, optional) - File pattern to search #### Advanced Workflow Tools - **vim_macro** - Record, stop, and play Vim macros - Input `action` ("record" | "stop" | "play"), `register` (string, a-z), `count` (number, optional) - **vim_tab** - Complete tab management - Input `action` ("new" | "close" | "next" | "prev" | "first" | "last" | "list"), `filename` (string, optional) - **vim_fold** - Code folding operations - Input `action` ("create" | "open" | "close" | "toggle" | "openall" | "closeall" | "delete"), `startLine`/`endLine` (numbers, for create) - **vim_jump** - Jump list navigation - Input `direction` ("back" | "forward" | "list") #### System Tools - **vim_health** - Check Neovim connection health and socket status Using this comprehensive set of **19 tools**, Claude can peer into your neovim session, navigate buffers, perform searches, make edits, record macros, manage tabs and folds, and handle your complete development workflow with standard Neovim features. ### Prompts - **neovim_workflow**: Get contextual help and guidance for common Neovim workflows including editing, navigation, search, buffer management, window operations, and macro usage. Provides step-by-step instructions for accomplishing tasks with the available MCP tools. ## Error Handling The server implements comprehensive error handling with custom error classes and consistent error responses: - **NeovimConnectionError**: Socket connection failures with detailed messages - **NeovimCommandError**: Command execution failures with command context - **NeovimValidationError**: Input validation failures **New in v0.5.2**: All tools now include robust try-catch error handling that returns meaningful error messages in proper MCP format. Features include connection health monitoring, graceful error propagation, and actionable error messages to help diagnose issues. ## Limitations - May not interact well with complex neovim configurations or plugins - Shell command execution is disabled by default for security - Socket connection required - won't work with standard vim ## Configuration ### Environment Variables - `ALLOW_SHELL_COMMANDS`: Set to 'true' to enable shell command execution (e.g. `!ls`). Defaults to false for security. - `NVIM_SOCKET_PATH`: Set to the path of your Neovim socket. Defaults to '/tmp/nvim' if not specified. ## Installation ### Option 1: DXT Package (Recommended) 1. Download the latest `.dxt` file from [Releases](https://github.com/bigcodegen/mcp-neovim-server/releases) 2. Drag the file to Claude Desktop ### Option 2: Manual Installation Add this to your `claude_desktop_config.json`: ```json { "mcpServers": { "MCP Neovim Server": { "command": "npx", "args": [ "-y", "mcp-neovim-server" ], "env": { "ALLOW_SHELL_COMMANDS": "true", "NVIM_SOCKET_PATH": "/tmp/nvim" } } } } ``` ## License This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository. ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "mcp-neovim-server", "version": "0.5.5", "description": "An MCP server for neovim", "type": "module", "bin": { "mcp-neovim-server": "build/index.js" }, "files": [ "build" ], "scripts": { "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", "prepare": "npm run build", "watch": "tsc --watch", "inspector": "npx @modelcontextprotocol/inspector build/index.js" }, "repository": { "type": "git", "url": "git+https://github.com/bigcodegen/mcp-neovim-server.git" }, "author": "bigcodegen", "license": "MIT", "bugs": { "url": "https://github.com/bigcodegen/mcp-neovim-server/issues" }, "homepage": "https://github.com/bigcodegen/mcp-neovim-server#readme", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.4", "neovim": "^5.3.0", "ts-node": "^10.9.2", "zod": "^3.25.76" }, "devDependencies": { "@types/node": "^24.3.0", "typescript": "^5.9.2" } } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * This is an MCP server that connects to neovim. */ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { NeovimManager, NeovimConnectionError, NeovimCommandError, NeovimValidationError } from "./neovim.js"; import { z } from "zod"; const server = new McpServer( { name: "mcp-neovim-server", version: "0.5.5" } ); const neovimManager = NeovimManager.getInstance(); // Register resources server.resource( "session", new ResourceTemplate("nvim://session", { list: () => ({ resources: [{ uri: "nvim://session", mimeType: "text/plain", name: "Current neovim session", description: "Current neovim text editor session" }] }) }), async (uri) => { const bufferContents = await neovimManager.getBufferContents(); return { contents: [{ uri: uri.href, mimeType: "text/plain", text: Array.from(bufferContents.entries()) .map(([lineNum, lineText]) => `${lineNum}: ${lineText}`) .join('\n') }] }; } ); server.resource( "buffers", new ResourceTemplate("nvim://buffers", { list: () => ({ resources: [{ uri: "nvim://buffers", mimeType: "application/json", name: "Open Neovim buffers", description: "List of all open buffers in the current Neovim session" }] }) }), async (uri) => { const openBuffers = await neovimManager.getOpenBuffers(); return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(openBuffers, null, 2) }] }; } ); // Register tools with proper parameter schemas server.tool( "vim_buffer", "Get buffer contents with line numbers", { filename: z.string().optional().describe("Optional file name to view a specific buffer") }, async ({ filename }) => { try { const bufferContents = await neovimManager.getBufferContents(filename); return { content: [{ type: "text", text: Array.from(bufferContents.entries()) .map(([lineNum, lineText]) => `${lineNum}: ${lineText}`) .join('\n') }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error getting buffer contents' }] }; } } ); server.tool( "vim_command", "Execute Vim commands with optional shell command support", { command: z.string().describe("Vim command to execute (use ! prefix for shell commands if enabled)") }, async ({ command }) => { try { // Check if this is a shell command if (command.startsWith('!')) { const allowShellCommands = process.env.ALLOW_SHELL_COMMANDS === 'true'; if (!allowShellCommands) { return { content: [{ type: "text", text: "Shell command execution is disabled. Set ALLOW_SHELL_COMMANDS=true environment variable to enable shell commands." }] }; } } const result = await neovimManager.sendCommand(command); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error executing command' }] }; } } ); server.tool( "vim_status", "Get comprehensive Neovim status including cursor position, mode, marks, and registers", {}, async () => { try { const status = await neovimManager.getNeovimStatus(); return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error getting Neovim status' }] }; } } ); server.tool( "vim_edit", "Edit buffer content using insert, replace, or replaceAll modes", { startLine: z.number().describe("The line number where editing should begin (1-indexed)"), mode: z.enum(["insert", "replace", "replaceAll"]).describe("Whether to insert new content, replace existing content, or replace entire buffer"), lines: z.string().describe("The text content to insert or use as replacement") }, async ({ startLine, mode, lines }) => { try { const result = await neovimManager.editLines(startLine, mode, lines); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error editing buffer' }] }; } } ); server.tool( "vim_window", "Manage Neovim windows: split, close, and navigate between windows", { command: z.enum(["split", "vsplit", "only", "close", "wincmd h", "wincmd j", "wincmd k", "wincmd l"]) .describe("Window manipulation command: split or vsplit to create new window, only to keep just current window, close to close current window, or wincmd with h/j/k/l to navigate between windows") }, async ({ command }) => { try { const result = await neovimManager.manipulateWindow(command); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error manipulating window' }] }; } } ); server.tool( "vim_mark", "Set named marks at specific positions in the buffer", { mark: z.string().regex(/^[a-z]$/).describe("Single lowercase letter [a-z] to use as the mark name"), line: z.number().describe("The line number where the mark should be placed (1-indexed)"), column: z.number().describe("The column number where the mark should be placed (0-indexed)") }, async ({ mark, line, column }) => { try { const result = await neovimManager.setMark(mark, line, column); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error setting mark' }] }; } } ); server.tool( "vim_register", "Manage Neovim register contents", { register: z.string().regex(/^[a-z"]$/).describe("Register name - a lowercase letter [a-z] or double-quote [\"] for the unnamed register"), content: z.string().describe("The text content to store in the specified register") }, async ({ register, content }) => { try { const result = await neovimManager.setRegister(register, content); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error setting register' }] }; } } ); server.tool( "vim_visual", "Create visual mode selections in the buffer", { startLine: z.number().describe("The starting line number for visual selection (1-indexed)"), startColumn: z.number().describe("The starting column number for visual selection (0-indexed)"), endLine: z.number().describe("The ending line number for visual selection (1-indexed)"), endColumn: z.number().describe("The ending column number for visual selection (0-indexed)") }, async ({ startLine, startColumn, endLine, endColumn }) => { try { const result = await neovimManager.visualSelect(startLine, startColumn, endLine, endColumn); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error creating visual selection' }] }; } } ); // New enhanced buffer management tools server.tool( "vim_buffer_switch", "Switch between buffers by name or number", { identifier: z.union([z.string(), z.number()]).describe("Buffer identifier - can be buffer number or filename/path") }, async ({ identifier }) => { try { const result = await neovimManager.switchBuffer(identifier); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error switching buffer' }] }; } } ); server.tool( "vim_buffer_save", "Save current buffer or save to specific filename", { filename: z.string().optional().describe("Optional filename to save buffer to (defaults to current buffer's filename)") }, async ({ filename }) => { try { const result = await neovimManager.saveBuffer(filename); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error saving buffer' }] }; } } ); server.tool( "vim_file_open", "Open files into new buffers", { filename: z.string().describe("Path to the file to open") }, async ({ filename }) => { try { const result = await neovimManager.openFile(filename); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error opening file' }] }; } } ); // New search and replace tools server.tool( "vim_search", "Search within current buffer with regex support and options", { pattern: z.string().describe("Search pattern (supports regex)"), ignoreCase: z.boolean().optional().describe("Whether to ignore case in search (default: false)"), wholeWord: z.boolean().optional().describe("Whether to match whole words only (default: false)") }, async ({ pattern, ignoreCase = false, wholeWord = false }) => { try { const result = await neovimManager.searchInBuffer(pattern, { ignoreCase, wholeWord }); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error searching in buffer' }] }; } } ); server.tool( "vim_search_replace", "Find and replace with global, case-insensitive, and confirm options", { pattern: z.string().describe("Search pattern (supports regex)"), replacement: z.string().describe("Replacement text"), global: z.boolean().optional().describe("Replace all occurrences in each line (default: false)"), ignoreCase: z.boolean().optional().describe("Whether to ignore case in search (default: false)"), confirm: z.boolean().optional().describe("Whether to confirm each replacement (default: false)") }, async ({ pattern, replacement, global = false, ignoreCase = false, confirm = false }) => { try { const result = await neovimManager.searchAndReplace(pattern, replacement, { global, ignoreCase, confirm }); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error in search and replace' }] }; } } ); server.tool( "vim_grep", "Project-wide search using vimgrep with quickfix list", { pattern: z.string().describe("Search pattern to grep for"), filePattern: z.string().optional().describe("File pattern to search in (default: **/* for all files)") }, async ({ pattern, filePattern = "**/*" }) => { try { const result = await neovimManager.grepInProject(pattern, filePattern); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error in grep search' }] }; } } ); // Health check tool server.tool( "vim_health", "Check Neovim connection health", {}, async () => { const isHealthy = await neovimManager.healthCheck(); return { content: [{ type: "text", text: isHealthy ? "Neovim connection is healthy" : "Neovim connection failed" }] }; } ); // Macro management tool server.tool( "vim_macro", "Record, stop, and play Neovim macros", { action: z.enum(["record", "stop", "play"]).describe("Action to perform with macros"), register: z.string().optional().describe("Register to record/play macro (a-z, required for record/play)"), count: z.number().optional().describe("Number of times to play macro (default: 1)") }, async ({ action, register, count = 1 }) => { try { const result = await neovimManager.manageMacro(action, register, count); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error managing macro' }] }; } } ); // Tab management tool server.tool( "vim_tab", "Manage Neovim tabs: create, close, and navigate between tabs", { action: z.enum(["new", "close", "next", "prev", "first", "last", "list"]).describe("Tab action to perform"), filename: z.string().optional().describe("Filename for new tab (optional)") }, async ({ action, filename }) => { try { const result = await neovimManager.manageTab(action, filename); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error managing tab' }] }; } } ); // Code folding tool server.tool( "vim_fold", "Manage code folding: create, open, close, and toggle folds", { action: z.enum(["create", "open", "close", "toggle", "openall", "closeall", "delete"]).describe("Folding action to perform"), startLine: z.number().optional().describe("Start line for creating fold (required for create)"), endLine: z.number().optional().describe("End line for creating fold (required for create)") }, async ({ action, startLine, endLine }) => { try { const result = await neovimManager.manageFold(action, startLine, endLine); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error managing fold' }] }; } } ); // Jump list navigation tool server.tool( "vim_jump", "Navigate Neovim jump list: go back, forward, or list jumps", { direction: z.enum(["back", "forward", "list"]).describe("Jump direction or list jumps") }, async ({ direction }) => { try { const result = await neovimManager.navigateJumpList(direction); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : 'Error navigating jump list' }] }; } } ); // Register a sample prompt for Neovim workflow assistance server.prompt( "neovim_workflow", "Get help with common Neovim workflows and editing tasks", { task: z.enum(["editing", "navigation", "search", "buffers", "windows", "macros"]).describe("Type of Neovim task you need help with") }, async ({ task }) => { const workflows = { editing: "Here are common editing workflows:\n1. Use vim_edit with 'insert' mode to add new content\n2. Use vim_edit with 'replace' mode to modify existing lines\n3. Use vim_search_replace for find and replace operations\n4. Use vim_visual to select text ranges before operations", navigation: "Navigation workflows:\n1. Use vim_mark to set bookmarks in your code\n2. Use vim_jump to navigate through your jump history\n3. Use vim_command with 'gg' or 'G' to go to start/end of file\n4. Use vim_command with line numbers like ':42' to jump to specific lines", search: "Search workflows:\n1. Use vim_search to find patterns in current buffer\n2. Use vim_grep for project-wide searches\n3. Use vim_search_replace for complex find/replace operations\n4. Use regex patterns for advanced matching", buffers: "Buffer management:\n1. Use vim_buffer to view buffer contents\n2. Use vim_buffer_switch to change between buffers\n3. Use vim_file_open to open new files\n4. Use vim_buffer_save to save your work", windows: "Window management:\n1. Use vim_window with 'split'/'vsplit' to create new windows\n2. Use vim_window with 'wincmd h/j/k/l' to navigate between windows\n3. Use vim_window with 'close' to close current window\n4. Use vim_window with 'only' to keep only current window", macros: "Macro workflows:\n1. Use vim_macro with 'record' and a register to start recording\n2. Perform your actions in Neovim\n3. Use vim_macro with 'stop' to end recording\n4. Use vim_macro with 'play' to execute recorded actions" }; return { messages: [ { role: "assistant", content: { type: "text", text: workflows[task] || "Unknown task type. Available tasks: editing, navigation, search, buffers, windows, macros" } } ] }; } ); /** * Start the server using stdio transport. * This allows the server to communicate via standard input/output streams. */ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((error) => { console.error("Server error:", error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/neovim.ts: -------------------------------------------------------------------------------- ```typescript import { attach, Neovim } from 'neovim'; export class NeovimConnectionError extends Error { constructor(socketPath: string, cause?: Error) { super(`Failed to connect to Neovim at ${socketPath}. Is Neovim running with --listen ${socketPath}?`); this.name = 'NeovimConnectionError'; this.cause = cause; } } export class NeovimCommandError extends Error { constructor(command: string, originalError: string) { super(`Failed to execute command '${command}': ${originalError}`); this.name = 'NeovimCommandError'; } } export class NeovimValidationError extends Error { constructor(message: string) { super(message); this.name = 'NeovimValidationError'; } } interface NeovimStatus { cursorPosition: [number, number]; mode: string; visualSelection: string; fileName: string; windowLayout: string; currentTab: number; marks: { [key: string]: [number, number] }; registers: { [key: string]: string }; cwd: string; lspInfo?: string; pluginInfo?: string; visualInfo?: { hasActiveSelection: boolean; visualModeType?: string; startPos?: [number, number]; endPos?: [number, number]; lastVisualStart?: [number, number]; lastVisualEnd?: [number, number]; }; } interface BufferInfo { number: number; name: string; isListed: boolean; isLoaded: boolean; modified: boolean; syntax: string; windowIds: number[]; } interface WindowInfo { id: number; bufferId: number; width: number; height: number; row: number; col: number; } export class NeovimManager { private static instance: NeovimManager; private constructor() { } public static getInstance(): NeovimManager { if (!NeovimManager.instance) { NeovimManager.instance = new NeovimManager(); } return NeovimManager.instance; } public async healthCheck(): Promise<boolean> { try { const nvim = await this.connect(); await nvim.eval('1'); // Simple test return true; } catch { return false; } } private validateSocketPath(path: string): void { if (!path || path.trim().length === 0) { throw new NeovimValidationError('Socket path cannot be empty'); } } private async connect(): Promise<Neovim> { const socketPath = process.env.NVIM_SOCKET_PATH || '/tmp/nvim'; this.validateSocketPath(socketPath); try { return attach({ socket: socketPath }); } catch (error) { console.error('Error connecting to Neovim:', error); throw new NeovimConnectionError(socketPath, error as Error); } } public async getBufferContents(filename?: string): Promise<Map<number, string>> { try { const nvim = await this.connect(); let buffer; if (filename) { // Find buffer by filename const buffers = await nvim.buffers; let targetBuffer = null; for (const buf of buffers) { const bufName = await buf.name; if (bufName === filename || bufName.endsWith(filename)) { targetBuffer = buf; break; } } if (!targetBuffer) { throw new NeovimValidationError(`Buffer not found: ${filename}`); } buffer = targetBuffer; } else { buffer = await nvim.buffer; } const lines = await buffer.lines; const lineMap = new Map<number, string>(); lines.forEach((line: string, index: number) => { lineMap.set(index + 1, line); }); return lineMap; } catch (error) { if (error instanceof NeovimValidationError) { throw error; } console.error('Error getting buffer contents:', error); return new Map(); } } public async sendCommand(command: string): Promise<string> { if (!command || command.trim().length === 0) { throw new NeovimValidationError('Command cannot be empty'); } try { const nvim = await this.connect(); // Remove leading colon if present const normalizedCommand = command.startsWith(':') ? command.substring(1) : command; // Handle shell commands (starting with !) if (normalizedCommand.startsWith('!')) { if (process.env.ALLOW_SHELL_COMMANDS !== 'true') { return 'Shell command execution is disabled. Set ALLOW_SHELL_COMMANDS=true environment variable to enable shell commands.'; } const shellCommand = normalizedCommand.substring(1).trim(); if (!shellCommand) { throw new NeovimValidationError('Shell command cannot be empty'); } try { // Execute the command and capture output directly const output = await nvim.eval(`system('${shellCommand.replace(/'/g, "''")}')`); if (output) { return String(output).trim(); } return 'No output from command'; } catch (error) { console.error('Shell command error:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; throw new NeovimCommandError(`!${shellCommand}`, errorMessage); } } // For regular Vim commands await nvim.setVvar('errmsg', ''); // Execute the command and capture its output using the execute() function const output = await nvim.call('execute', [normalizedCommand]); // Check for errors const vimerr = await nvim.getVvar('errmsg'); if (vimerr) { console.error('Vim error:', vimerr); throw new NeovimCommandError(normalizedCommand, String(vimerr)); } // Return the actual command output if any return output ? String(output).trim() : 'Command executed (no output)'; } catch (error) { if (error instanceof NeovimCommandError || error instanceof NeovimValidationError) { throw error; } console.error('Error sending command:', error); throw new NeovimCommandError(command, error instanceof Error ? error.message : 'Unknown error'); } } private async getVisualSelectionInfo(nvim: Neovim, mode: string): Promise<{ hasSelection: boolean; selectedText?: string; startPos?: [number, number]; endPos?: [number, number]; visualModeType?: string; lastVisualStart?: [number, number]; lastVisualEnd?: [number, number]; }> { try { const isInVisualMode = mode.includes('v') || mode.includes('V') || mode.includes('\x16'); if (isInVisualMode) { // Currently in visual mode - get active selection const [startPos, endPos, initialVisualModeType] = await Promise.all([ nvim.call('getpos', ['v']) as Promise<[number, number, number, number]>, nvim.call('getpos', ['.']) as Promise<[number, number, number, number]>, nvim.call('visualmode', []) as Promise<string> ]); // Convert positions to [line, col] format const start: [number, number] = [startPos[1], startPos[2]]; const end: [number, number] = [endPos[1], endPos[2]]; // Get the selected text using a more reliable approach let selectedText = ''; let visualModeType = initialVisualModeType; try { const result = await nvim.lua(` -- Get visual mode type first local mode = vim.fn.visualmode() if not mode or mode == '' then return { text = '', mode = '' } end local start_pos = vim.fn.getpos('v') local end_pos = vim.fn.getpos('.') local start_line, start_col = start_pos[2], start_pos[3] local end_line, end_col = end_pos[2], end_pos[3] -- Ensure proper ordering (start should be before end) if start_line > end_line or (start_line == end_line and start_col > end_col) then start_line, end_line = end_line, start_line start_col, end_col = end_col, start_col end local text = '' if mode == 'v' then -- Character-wise visual mode if start_line == end_line then local line = vim.api.nvim_buf_get_lines(0, start_line - 1, start_line, false)[1] or '' text = line:sub(start_col, end_col) else local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) if #lines > 0 then -- Handle first line lines[1] = lines[1]:sub(start_col) -- Handle last line if #lines > 1 then lines[#lines] = lines[#lines]:sub(1, end_col) end text = table.concat(lines, '\\n') end end elseif mode == 'V' then -- Line-wise visual mode local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) text = table.concat(lines, '\\n') elseif mode == '\\022' then -- Block-wise visual mode (Ctrl-V) local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) local result = {} for _, line in ipairs(lines) do table.insert(result, line:sub(start_col, end_col)) end text = table.concat(result, '\\n') end return { text = text, mode = mode } `) as { text: string; mode: string }; selectedText = result.text || ''; visualModeType = result.mode || visualModeType; } catch (e) { selectedText = '[Selection text unavailable]'; } return { hasSelection: true, selectedText, startPos: start, endPos: end, visualModeType }; } else { // Not in visual mode - get last visual selection marks try { const [lastStart, lastEnd] = await Promise.all([ nvim.call('getpos', ["'<"]) as Promise<[number, number, number, number]>, nvim.call('getpos', ["'>"]) as Promise<[number, number, number, number]> ]); return { hasSelection: false, lastVisualStart: [lastStart[1], lastStart[2]], lastVisualEnd: [lastEnd[1], lastEnd[2]] }; } catch (e) { return { hasSelection: false }; } } } catch (error) { return { hasSelection: false, selectedText: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } public async getNeovimStatus(): Promise<NeovimStatus | string> { try { const nvim = await this.connect(); const window = await nvim.window; const cursor = await window.cursor; const mode = await nvim.mode; const buffer = await nvim.buffer; // Get window layout const layout = await nvim.eval('winlayout()'); const tabpage = await nvim.tabpage; const currentTab = await tabpage.number; // Get marks (a-z) - only include set marks const marks: { [key: string]: [number, number] } = {}; for (const mark of 'abcdefghijklmnopqrstuvwxyz') { try { const pos = await nvim.eval(`getpos("'${mark}")`) as [number, number, number, number]; // Only include marks that are actually set (not at position 0,0) if (pos[1] > 0 && pos[2] > 0) { marks[mark] = [pos[1], pos[2]]; } } catch (e) { // Mark not set } } // Get registers (a-z, ", 0-9) - only include non-empty registers const registers: { [key: string]: string } = {}; const registerNames = [...'abcdefghijklmnopqrstuvwxyz', '"', ...Array(10).keys()]; for (const reg of registerNames) { try { const content = String(await nvim.eval(`getreg('${reg}')`)); // Only include registers that have content if (content && content.trim().length > 0) { registers[String(reg)] = content; } } catch (e) { // Register empty or error } } // Get current working directory const cwd = await nvim.call('getcwd'); // Get basic plugin information (LSP clients, loaded plugins) let lspInfo = ''; let pluginInfo = ''; try { // Get LSP clients if available (use new API for Neovim >=0.10) const lspClients = await nvim.eval('luaeval("vim.lsp.get_clients()")'); if (Array.isArray(lspClients) && lspClients.length > 0) { const clientNames = lspClients.map((client: any) => client.name || 'unknown').join(', '); lspInfo = `Active LSP clients: ${clientNames}`; } else { lspInfo = 'No active LSP clients'; } } catch (e) { lspInfo = 'LSP information unavailable'; } try { // Get loaded plugins (simplified check) const hasLsp = await nvim.eval('exists(":LspInfo")'); const hasTelescope = await nvim.eval('exists(":Telescope")'); const hasTreesitter = await nvim.eval('exists("g:loaded_nvim_treesitter")'); const hasCompletion = await nvim.eval('exists("g:loaded_completion")'); const plugins = []; if (hasLsp) plugins.push('LSP'); if (hasTelescope) plugins.push('Telescope'); if (hasTreesitter) plugins.push('TreeSitter'); if (hasCompletion) plugins.push('Completion'); pluginInfo = plugins.length > 0 ? `Detected plugins: ${plugins.join(', ')}` : 'No common plugins detected'; } catch (e) { pluginInfo = 'Plugin information unavailable'; } // Get visual selection information using the new method const visualInfo = await this.getVisualSelectionInfo(nvim, mode.mode); const neovimStatus: NeovimStatus = { cursorPosition: cursor, mode: mode.mode, visualSelection: visualInfo.selectedText || '', fileName: await buffer.name, windowLayout: JSON.stringify(layout), currentTab, marks, registers, cwd, lspInfo, pluginInfo, visualInfo: { hasActiveSelection: visualInfo.hasSelection, visualModeType: visualInfo.visualModeType, startPos: visualInfo.startPos, endPos: visualInfo.endPos, lastVisualStart: visualInfo.lastVisualStart, lastVisualEnd: visualInfo.lastVisualEnd } }; return neovimStatus; } catch (error) { console.error('Error getting Neovim status:', error); return 'Error getting Neovim status'; } } public async editLines(startLine: number, mode: 'replace' | 'insert' | 'replaceAll', newText: string): Promise<string> { try { const nvim = await this.connect(); const splitByLines = newText.split('\n'); const buffer = await nvim.buffer; if (mode === 'replaceAll') { // Handle full buffer replacement const lineCount = await buffer.length; // Delete all lines and then append new content await buffer.remove(0, lineCount, true); await buffer.insert(splitByLines, 0); return 'Buffer completely replaced'; } else if (mode === 'replace') { await buffer.replace(splitByLines, startLine - 1); return 'Lines replaced successfully'; } else if (mode === 'insert') { await buffer.insert(splitByLines, startLine - 1); return 'Lines inserted successfully'; } return 'Invalid mode specified'; } catch (error) { console.error('Error editing lines:', error); return 'Error editing lines'; } } public async getWindows(): Promise<WindowInfo[]> { try { const nvim = await this.connect(); const windows = await nvim.windows; const windowInfos: WindowInfo[] = []; for (const win of windows) { const buffer = await win.buffer; const [width, height] = await Promise.all([ win.width, win.height ]); const position = await win.position; windowInfos.push({ id: win.id, bufferId: buffer.id, width, height, row: position[0], col: position[1] }); } return windowInfos; } catch (error) { console.error('Error getting windows:', error); return []; } } public async manipulateWindow(command: string): Promise<string> { const validCommands = ['split', 'vsplit', 'only', 'close', 'wincmd h', 'wincmd j', 'wincmd k', 'wincmd l']; if (!validCommands.some(cmd => command.startsWith(cmd))) { return 'Invalid window command'; } try { const nvim = await this.connect(); await nvim.command(command); return 'Window command executed'; } catch (error) { console.error('Error manipulating window:', error); return 'Error executing window command'; } } public async setMark(mark: string, line: number, col: number): Promise<string> { if (!/^[a-z]$/.test(mark)) { return 'Invalid mark name (must be a-z)'; } try { const nvim = await this.connect(); await nvim.command(`mark ${mark}`); const window = await nvim.window; await (window.cursor = [line, col]); return `Mark ${mark} set at line ${line}, column ${col}`; } catch (error) { console.error('Error setting mark:', error); return 'Error setting mark'; } } public async setRegister(register: string, content: string): Promise<string> { const validRegisters = [...'abcdefghijklmnopqrstuvwxyz"']; if (!validRegisters.includes(register)) { return 'Invalid register name'; } try { const nvim = await this.connect(); await nvim.eval(`setreg('${register}', '${content.replace(/'/g, "''")}')`); return `Register ${register} set`; } catch (error) { console.error('Error setting register:', error); return 'Error setting register'; } } public async visualSelect(startLine: number, startCol: number, endLine: number, endCol: number): Promise<string> { try { const nvim = await this.connect(); const window = await nvim.window; // Enter visual mode await nvim.command('normal! v'); // Move cursor to start position await (window.cursor = [startLine, startCol]); // Move cursor to end position (selection will be made) await (window.cursor = [endLine, endCol]); return 'Visual selection made'; } catch (error) { console.error('Error making visual selection:', error); return 'Error making visual selection'; } } public async switchBuffer(identifier: string | number): Promise<string> { try { const nvim = await this.connect(); // If identifier is a number, switch by buffer number if (typeof identifier === 'number') { await nvim.command(`buffer ${identifier}`); return `Switched to buffer ${identifier}`; } // If identifier is a string, try to find buffer by name const buffers = await nvim.buffers; for (const buffer of buffers) { const bufName = await buffer.name; if (bufName === identifier || bufName.endsWith(identifier)) { await nvim.command(`buffer ${buffer.id}`); return `Switched to buffer: ${bufName}`; } } throw new NeovimValidationError(`Buffer not found: ${identifier}`); } catch (error) { if (error instanceof NeovimValidationError) { throw error; } console.error('Error switching buffer:', error); throw new NeovimCommandError(`buffer switch to ${identifier}`, error instanceof Error ? error.message : 'Unknown error'); } } public async saveBuffer(filename?: string): Promise<string> { try { const nvim = await this.connect(); if (filename) { // Save with specific filename await nvim.command(`write ${filename}`); return `Buffer saved to: ${filename}`; } else { // Save current buffer const buffer = await nvim.buffer; const bufferName = await buffer.name; if (!bufferName) { throw new NeovimValidationError('Cannot save unnamed buffer without specifying filename'); } await nvim.command('write'); return `Buffer saved: ${bufferName}`; } } catch (error) { if (error instanceof NeovimValidationError) { throw error; } console.error('Error saving buffer:', error); throw new NeovimCommandError(`save ${filename || 'current buffer'}`, error instanceof Error ? error.message : 'Unknown error'); } } public async openFile(filename: string): Promise<string> { if (!filename || filename.trim().length === 0) { throw new NeovimValidationError('Filename cannot be empty'); } try { const nvim = await this.connect(); await nvim.command(`edit ${filename}`); return `Opened file: ${filename}`; } catch (error) { console.error('Error opening file:', error); throw new NeovimCommandError(`edit ${filename}`, error instanceof Error ? error.message : 'Unknown error'); } } public async searchInBuffer(pattern: string, options: { ignoreCase?: boolean; wholeWord?: boolean } = {}): Promise<string> { if (!pattern || pattern.trim().length === 0) { throw new NeovimValidationError('Search pattern cannot be empty'); } try { const nvim = await this.connect(); // Build search command with options let searchPattern = pattern; if (options.wholeWord) { searchPattern = `\\<${pattern}\\>`; } // Set search options if (options.ignoreCase) { await nvim.command('set ignorecase'); } else { await nvim.command('set noignorecase'); } // Perform search and get matches const matches = await nvim.eval(`searchcount({"pattern": "${searchPattern.replace(/"/g, '\\"')}", "maxcount": 100})`); const matchInfo = matches as { current: number; total: number; maxcount: number; incomplete: number }; if (matchInfo.total === 0) { return `No matches found for: ${pattern}`; } // Move to first match await nvim.command(`/${searchPattern}`); return `Found ${matchInfo.total} matches for: ${pattern}${matchInfo.incomplete ? ' (showing first 100)' : ''}`; } catch (error) { console.error('Error searching in buffer:', error); throw new NeovimCommandError(`search for ${pattern}`, error instanceof Error ? error.message : 'Unknown error'); } } public async searchAndReplace(pattern: string, replacement: string, options: { global?: boolean; ignoreCase?: boolean; confirm?: boolean } = {}): Promise<string> { if (!pattern || pattern.trim().length === 0) { throw new NeovimValidationError('Search pattern cannot be empty'); } try { const nvim = await this.connect(); // Build substitute command let flags = ''; if (options.global) flags += 'g'; if (options.ignoreCase) flags += 'i'; if (options.confirm) flags += 'c'; const command = `%s/${pattern.replace(/\//g, '\\/')}/${replacement.replace(/\//g, '\\/')}/${flags}`; const result = await nvim.call('execute', [command]); return result ? String(result).trim() : 'Search and replace completed'; } catch (error) { console.error('Error in search and replace:', error); throw new NeovimCommandError(`substitute ${pattern} -> ${replacement}`, error instanceof Error ? error.message : 'Unknown error'); } } public async grepInProject(pattern: string, filePattern: string = '**/*'): Promise<string> { if (!pattern || pattern.trim().length === 0) { throw new NeovimValidationError('Grep pattern cannot be empty'); } try { const nvim = await this.connect(); // Use vimgrep for internal searching const command = `vimgrep /${pattern}/ ${filePattern}`; await nvim.command(command); // Get quickfix list const qflist = await nvim.eval('getqflist()'); const results = qflist as Array<{ filename: string; lnum: number; text: string }>; if (results.length === 0) { return `No matches found for: ${pattern}`; } const summary = results.slice(0, 10).map(item => `${item.filename}:${item.lnum}: ${item.text.trim()}` ).join('\n'); const totalText = results.length > 10 ? `\n... and ${results.length - 10} more matches` : ''; return `Found ${results.length} matches for: ${pattern}\n${summary}${totalText}`; } catch (error) { console.error('Error in grep:', error); throw new NeovimCommandError(`grep ${pattern}`, error instanceof Error ? error.message : 'Unknown error'); } } public async getOpenBuffers(): Promise<BufferInfo[]> { try { const nvim = await this.connect(); const buffers = await nvim.buffers; const windows = await nvim.windows; const bufferInfos: BufferInfo[] = []; for (const buffer of buffers) { const [ isLoaded, isListedOption, modified, syntax ] = await Promise.all([ buffer.loaded, buffer.getOption('buflisted'), buffer.getOption('modified'), buffer.getOption('syntax') ]); const isListed = Boolean(isListedOption); // Find windows containing this buffer const windowIds = []; for (const win of windows) { const winBuffer = await win.buffer; if (winBuffer.id === buffer.id) { windowIds.push(win.id); } } bufferInfos.push({ number: buffer.id, name: await buffer.name, isListed, isLoaded, modified: Boolean(modified), syntax: String(syntax), windowIds }); } return bufferInfos; } catch (error) { console.error('Error getting open buffers:', error); return []; } } public async manageMacro(action: string, register?: string, count: number = 1): Promise<string> { try { const nvim = await this.connect(); switch (action) { case 'record': if (!register || register.length !== 1 || !/[a-z]/.test(register)) { throw new NeovimValidationError('Register must be a single letter a-z for recording'); } await nvim.input(`q${register}`); return `Started recording macro in register '${register}'`; case 'stop': await nvim.input('q'); return 'Stopped recording macro'; case 'play': if (!register || register.length !== 1 || !/[a-z]/.test(register)) { throw new NeovimValidationError('Register must be a single letter a-z for playing'); } const playCommand = count > 1 ? `${count}@${register}` : `@${register}`; await nvim.input(playCommand); return `Played macro from register '${register}' ${count} time(s)`; default: throw new NeovimValidationError(`Unknown macro action: ${action}`); } } catch (error) { if (error instanceof NeovimValidationError) { throw error; } console.error('Error managing macro:', error); throw new NeovimCommandError(`macro ${action}`, error instanceof Error ? error.message : 'Unknown error'); } } public async manageTab(action: string, filename?: string): Promise<string> { try { const nvim = await this.connect(); switch (action) { case 'new': if (filename) { await nvim.command(`tabnew ${filename}`); return `Created new tab with file: ${filename}`; } else { await nvim.command('tabnew'); return 'Created new empty tab'; } case 'close': await nvim.command('tabclose'); return 'Closed current tab'; case 'next': await nvim.command('tabnext'); return 'Moved to next tab'; case 'prev': await nvim.command('tabprev'); return 'Moved to previous tab'; case 'first': await nvim.command('tabfirst'); return 'Moved to first tab'; case 'last': await nvim.command('tablast'); return 'Moved to last tab'; case 'list': const tabs = await nvim.tabpages; const tabInfo = []; for (let i = 0; i < tabs.length; i++) { const tab = tabs[i]; const win = await tab.window; const buf = await win.buffer; const name = await buf.name; const current = await nvim.tabpage; const isCurrent = tab === current; tabInfo.push(`${isCurrent ? '*' : ' '}${i + 1}: ${name || '[No Name]'}`); } return `Tabs:\n${tabInfo.join('\n')}`; default: throw new NeovimValidationError(`Unknown tab action: ${action}`); } } catch (error) { if (error instanceof NeovimValidationError) { throw error; } console.error('Error managing tab:', error); throw new NeovimCommandError(`tab ${action}`, error instanceof Error ? error.message : 'Unknown error'); } } public async manageFold(action: string, startLine?: number, endLine?: number): Promise<string> { try { const nvim = await this.connect(); switch (action) { case 'create': if (startLine === undefined || endLine === undefined) { throw new NeovimValidationError('Start line and end line are required for creating folds'); } await nvim.command(`${startLine},${endLine}fold`); return `Created fold from line ${startLine} to ${endLine}`; case 'open': await nvim.input('zo'); return 'Opened fold at cursor'; case 'close': await nvim.input('zc'); return 'Closed fold at cursor'; case 'toggle': await nvim.input('za'); return 'Toggled fold at cursor'; case 'openall': await nvim.command('normal! zR'); return 'Opened all folds'; case 'closeall': await nvim.command('normal! zM'); return 'Closed all folds'; case 'delete': await nvim.input('zd'); return 'Deleted fold at cursor'; default: throw new NeovimValidationError(`Unknown fold action: ${action}`); } } catch (error) { if (error instanceof NeovimValidationError) { throw error; } console.error('Error managing fold:', error); throw new NeovimCommandError(`fold ${action}`, error instanceof Error ? error.message : 'Unknown error'); } } public async navigateJumpList(direction: string): Promise<string> { try { const nvim = await this.connect(); switch (direction) { case 'back': await nvim.input('\x0f'); // Ctrl-O return 'Jumped back in jump list'; case 'forward': await nvim.input('\x09'); // Ctrl-I (Tab) return 'Jumped forward in jump list'; case 'list': await nvim.command('jumps'); // Get the output from the command const output = await nvim.eval('execute("jumps")'); return `Jump list:\n${output}`; default: throw new NeovimValidationError(`Unknown jump direction: ${direction}`); } } catch (error) { if (error instanceof NeovimValidationError) { throw error; } console.error('Error navigating jump list:', error); throw new NeovimCommandError(`jump ${direction}`, error instanceof Error ? error.message : 'Unknown error'); } } } ```