# Directory Structure ``` ├── .gitignore ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── index.ts │ └── neovim.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build output 5 | build/ 6 | dist/ 7 | 8 | # Environment variables 9 | .env 10 | 11 | # IDE and OS files 12 | .DS_Store 13 | .vscode/ 14 | .idea/ 15 | 16 | # Logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Neovim MCP Server 2 | 3 | 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. 4 | 5 | <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> 6 | 7 | ## Features 8 | 9 | - Connects to your nvim instance if you expose a socket file, for example `--listen /tmp/nvim`, when starting nvim 10 | - Views your current buffers and manages buffer switching 11 | - Gets cursor location, mode, file name, marks, registers, and visual selections 12 | - Runs vim commands and optionally shell commands through vim 13 | - Can make edits using insert, replace, or replaceAll modes 14 | - Search and replace functionality with regex support 15 | - Project-wide grep search with quickfix integration 16 | - Comprehensive window management 17 | - Health monitoring and connection diagnostics 18 | 19 | ## API 20 | 21 | ### Resources 22 | 23 | - `nvim://session`: Current neovim text editor session 24 | - `nvim://buffers`: List of all open buffers in the current Neovim session with metadata including modified status, syntax, and window IDs 25 | 26 | ### Tools 27 | 28 | #### Core Tools 29 | - **vim_buffer** 30 | - Get buffer contents with line numbers (supports filename parameter) 31 | - Input `filename` (string, optional) - Get specific buffer by filename 32 | - Returns numbered lines with buffer content 33 | - **vim_command** 34 | - Send a command to VIM for navigation, spot editing, and line deletion 35 | - Input `command` (string) 36 | - Runs vim commands with `nvim.replaceTermcodes`. Multiple commands work with newlines 37 | - Shell commands supported with `!` prefix when `ALLOW_SHELL_COMMANDS=true` 38 | - On error, `'nvim:errmsg'` contents are returned 39 | - **vim_status** 40 | - Get comprehensive Neovim status 41 | - Returns cursor position, mode, filename, visual selection with enhanced detection, window layout, current tab, marks, registers, working directory, LSP client info, and plugin detection 42 | - Enhanced visual selection reporting: detects visual mode type (character/line/block), provides accurate selection text, start/end positions, and last visual selection marks 43 | - **vim_edit** 44 | - Edit lines using insert, replace, or replaceAll modes 45 | - Input `startLine` (number), `mode` (`"insert"` | `"replace"` | `"replaceAll"`), `lines` (string) 46 | - insert: insert lines at startLine 47 | - replace: replace lines starting at startLine 48 | - replaceAll: replace entire buffer contents 49 | - **vim_window** 50 | - Manipulate Neovim windows (split, vsplit, close, navigate) 51 | - Input `command` (string: "split", "vsplit", "only", "close", "wincmd h/j/k/l") 52 | - **vim_mark** 53 | - Set named marks at specific positions 54 | - Input `mark` (string: a-z), `line` (number), `column` (number) 55 | - **vim_register** 56 | - Set content of registers 57 | - Input `register` (string: a-z or "), `content` (string) 58 | - **vim_visual** 59 | - Create visual mode selections 60 | - Input `startLine` (number), `startColumn` (number), `endLine` (number), `endColumn` (number) 61 | 62 | #### Enhanced Buffer Management 63 | - **vim_buffer_switch** 64 | - Switch between buffers by name or number 65 | - Input `identifier` (string | number) - Buffer name or number 66 | - **vim_buffer_save** 67 | - Save current buffer or save to specific filename 68 | - Input `filename` (string, optional) - Save to specific file 69 | - **vim_file_open** 70 | - Open files into new buffers 71 | - Input `filename` (string) - File to open 72 | 73 | #### Search and Replace 74 | - **vim_search** 75 | - Search within current buffer with regex support 76 | - Input `pattern` (string), `ignoreCase` (boolean, optional), `wholeWord` (boolean, optional) 77 | - **vim_search_replace** 78 | - Find and replace with advanced options 79 | - Input `pattern` (string), `replacement` (string), `global` (boolean, optional), `ignoreCase` (boolean, optional), `confirm` (boolean, optional) 80 | - **vim_grep** 81 | - Project-wide search using vimgrep with quickfix list 82 | - Input `pattern` (string), `filePattern` (string, optional) - File pattern to search 83 | 84 | #### Advanced Workflow Tools 85 | - **vim_macro** 86 | - Record, stop, and play Vim macros 87 | - Input `action` ("record" | "stop" | "play"), `register` (string, a-z), `count` (number, optional) 88 | - **vim_tab** 89 | - Complete tab management 90 | - Input `action` ("new" | "close" | "next" | "prev" | "first" | "last" | "list"), `filename` (string, optional) 91 | - **vim_fold** 92 | - Code folding operations 93 | - Input `action` ("create" | "open" | "close" | "toggle" | "openall" | "closeall" | "delete"), `startLine`/`endLine` (numbers, for create) 94 | - **vim_jump** 95 | - Jump list navigation 96 | - Input `direction` ("back" | "forward" | "list") 97 | 98 | #### System Tools 99 | - **vim_health** 100 | - Check Neovim connection health and socket status 101 | 102 | 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. 103 | 104 | ### Prompts 105 | 106 | - **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. 107 | 108 | ## Error Handling 109 | 110 | The server implements comprehensive error handling with custom error classes and consistent error responses: 111 | 112 | - **NeovimConnectionError**: Socket connection failures with detailed messages 113 | - **NeovimCommandError**: Command execution failures with command context 114 | - **NeovimValidationError**: Input validation failures 115 | 116 | **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. 117 | 118 | ## Limitations 119 | 120 | - May not interact well with complex neovim configurations or plugins 121 | - Shell command execution is disabled by default for security 122 | - Socket connection required - won't work with standard vim 123 | 124 | ## Configuration 125 | 126 | ### Environment Variables 127 | 128 | - `ALLOW_SHELL_COMMANDS`: Set to 'true' to enable shell command execution (e.g. `!ls`). Defaults to false for security. 129 | - `NVIM_SOCKET_PATH`: Set to the path of your Neovim socket. Defaults to '/tmp/nvim' if not specified. 130 | 131 | ## Installation 132 | 133 | ### Option 1: DXT Package (Recommended) 134 | 1. Download the latest `.dxt` file from [Releases](https://github.com/bigcodegen/mcp-neovim-server/releases) 135 | 2. Drag the file to Claude Desktop 136 | 137 | ### Option 2: Manual Installation 138 | Add this to your `claude_desktop_config.json`: 139 | ```json 140 | { 141 | "mcpServers": { 142 | "MCP Neovim Server": { 143 | "command": "npx", 144 | "args": [ 145 | "-y", 146 | "mcp-neovim-server" 147 | ], 148 | "env": { 149 | "ALLOW_SHELL_COMMANDS": "true", 150 | "NVIM_SOCKET_PATH": "/tmp/nvim" 151 | } 152 | } 153 | } 154 | } 155 | ``` 156 | 157 | ## License 158 | 159 | 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. 160 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "mcp-neovim-server", 3 | "version": "0.5.5", 4 | "description": "An MCP server for neovim", 5 | "type": "module", 6 | "bin": { 7 | "mcp-neovim-server": "build/index.js" 8 | }, 9 | "files": [ 10 | "build" 11 | ], 12 | "scripts": { 13 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 14 | "prepare": "npm run build", 15 | "watch": "tsc --watch", 16 | "inspector": "npx @modelcontextprotocol/inspector build/index.js" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/bigcodegen/mcp-neovim-server.git" 21 | }, 22 | "author": "bigcodegen", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/bigcodegen/mcp-neovim-server/issues" 26 | }, 27 | "homepage": "https://github.com/bigcodegen/mcp-neovim-server#readme", 28 | "dependencies": { 29 | "@modelcontextprotocol/sdk": "^1.17.4", 30 | "neovim": "^5.3.0", 31 | "ts-node": "^10.9.2", 32 | "zod": "^3.25.76" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^24.3.0", 36 | "typescript": "^5.9.2" 37 | } 38 | } 39 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * This is an MCP server that connects to neovim. 5 | */ 6 | 7 | import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; 8 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 9 | import { NeovimManager, NeovimConnectionError, NeovimCommandError, NeovimValidationError } from "./neovim.js"; 10 | import { z } from "zod"; 11 | 12 | const server = new McpServer( 13 | { 14 | name: "mcp-neovim-server", 15 | version: "0.5.5" 16 | } 17 | ); 18 | 19 | const neovimManager = NeovimManager.getInstance(); 20 | 21 | // Register resources 22 | server.resource( 23 | "session", 24 | new ResourceTemplate("nvim://session", { 25 | list: () => ({ 26 | resources: [{ 27 | uri: "nvim://session", 28 | mimeType: "text/plain", 29 | name: "Current neovim session", 30 | description: "Current neovim text editor session" 31 | }] 32 | }) 33 | }), 34 | async (uri) => { 35 | const bufferContents = await neovimManager.getBufferContents(); 36 | return { 37 | contents: [{ 38 | uri: uri.href, 39 | mimeType: "text/plain", 40 | text: Array.from(bufferContents.entries()) 41 | .map(([lineNum, lineText]) => `${lineNum}: ${lineText}`) 42 | .join('\n') 43 | }] 44 | }; 45 | } 46 | ); 47 | 48 | server.resource( 49 | "buffers", 50 | new ResourceTemplate("nvim://buffers", { 51 | list: () => ({ 52 | resources: [{ 53 | uri: "nvim://buffers", 54 | mimeType: "application/json", 55 | name: "Open Neovim buffers", 56 | description: "List of all open buffers in the current Neovim session" 57 | }] 58 | }) 59 | }), 60 | async (uri) => { 61 | const openBuffers = await neovimManager.getOpenBuffers(); 62 | return { 63 | contents: [{ 64 | uri: uri.href, 65 | mimeType: "application/json", 66 | text: JSON.stringify(openBuffers, null, 2) 67 | }] 68 | }; 69 | } 70 | ); 71 | 72 | // Register tools with proper parameter schemas 73 | server.tool( 74 | "vim_buffer", 75 | "Get buffer contents with line numbers", 76 | { filename: z.string().optional().describe("Optional file name to view a specific buffer") }, 77 | async ({ filename }) => { 78 | try { 79 | const bufferContents = await neovimManager.getBufferContents(filename); 80 | return { 81 | content: [{ 82 | type: "text", 83 | text: Array.from(bufferContents.entries()) 84 | .map(([lineNum, lineText]) => `${lineNum}: ${lineText}`) 85 | .join('\n') 86 | }] 87 | }; 88 | } catch (error) { 89 | return { 90 | content: [{ 91 | type: "text", 92 | text: error instanceof Error ? error.message : 'Error getting buffer contents' 93 | }] 94 | }; 95 | } 96 | } 97 | ); 98 | 99 | server.tool( 100 | "vim_command", 101 | "Execute Vim commands with optional shell command support", 102 | { command: z.string().describe("Vim command to execute (use ! prefix for shell commands if enabled)") }, 103 | async ({ command }) => { 104 | try { 105 | // Check if this is a shell command 106 | if (command.startsWith('!')) { 107 | const allowShellCommands = process.env.ALLOW_SHELL_COMMANDS === 'true'; 108 | if (!allowShellCommands) { 109 | return { 110 | content: [{ 111 | type: "text", 112 | text: "Shell command execution is disabled. Set ALLOW_SHELL_COMMANDS=true environment variable to enable shell commands." 113 | }] 114 | }; 115 | } 116 | } 117 | 118 | const result = await neovimManager.sendCommand(command); 119 | return { 120 | content: [{ 121 | type: "text", 122 | text: result 123 | }] 124 | }; 125 | } catch (error) { 126 | return { 127 | content: [{ 128 | type: "text", 129 | text: error instanceof Error ? error.message : 'Error executing command' 130 | }] 131 | }; 132 | } 133 | } 134 | ); 135 | 136 | server.tool( 137 | "vim_status", 138 | "Get comprehensive Neovim status including cursor position, mode, marks, and registers", 139 | {}, 140 | async () => { 141 | try { 142 | const status = await neovimManager.getNeovimStatus(); 143 | return { 144 | content: [{ 145 | type: "text", 146 | text: JSON.stringify(status, null, 2) 147 | }] 148 | }; 149 | } catch (error) { 150 | return { 151 | content: [{ 152 | type: "text", 153 | text: error instanceof Error ? error.message : 'Error getting Neovim status' 154 | }] 155 | }; 156 | } 157 | } 158 | ); 159 | 160 | server.tool( 161 | "vim_edit", 162 | "Edit buffer content using insert, replace, or replaceAll modes", 163 | { 164 | startLine: z.number().describe("The line number where editing should begin (1-indexed)"), 165 | mode: z.enum(["insert", "replace", "replaceAll"]).describe("Whether to insert new content, replace existing content, or replace entire buffer"), 166 | lines: z.string().describe("The text content to insert or use as replacement") 167 | }, 168 | async ({ startLine, mode, lines }) => { 169 | try { 170 | const result = await neovimManager.editLines(startLine, mode, lines); 171 | return { 172 | content: [{ 173 | type: "text", 174 | text: result 175 | }] 176 | }; 177 | } catch (error) { 178 | return { 179 | content: [{ 180 | type: "text", 181 | text: error instanceof Error ? error.message : 'Error editing buffer' 182 | }] 183 | }; 184 | } 185 | } 186 | ); 187 | 188 | server.tool( 189 | "vim_window", 190 | "Manage Neovim windows: split, close, and navigate between windows", 191 | { 192 | command: z.enum(["split", "vsplit", "only", "close", "wincmd h", "wincmd j", "wincmd k", "wincmd l"]) 193 | .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") 194 | }, 195 | async ({ command }) => { 196 | try { 197 | const result = await neovimManager.manipulateWindow(command); 198 | return { 199 | content: [{ 200 | type: "text", 201 | text: result 202 | }] 203 | }; 204 | } catch (error) { 205 | return { 206 | content: [{ 207 | type: "text", 208 | text: error instanceof Error ? error.message : 'Error manipulating window' 209 | }] 210 | }; 211 | } 212 | } 213 | ); 214 | 215 | server.tool( 216 | "vim_mark", 217 | "Set named marks at specific positions in the buffer", 218 | { 219 | mark: z.string().regex(/^[a-z]$/).describe("Single lowercase letter [a-z] to use as the mark name"), 220 | line: z.number().describe("The line number where the mark should be placed (1-indexed)"), 221 | column: z.number().describe("The column number where the mark should be placed (0-indexed)") 222 | }, 223 | async ({ mark, line, column }) => { 224 | try { 225 | const result = await neovimManager.setMark(mark, line, column); 226 | return { 227 | content: [{ 228 | type: "text", 229 | text: result 230 | }] 231 | }; 232 | } catch (error) { 233 | return { 234 | content: [{ 235 | type: "text", 236 | text: error instanceof Error ? error.message : 'Error setting mark' 237 | }] 238 | }; 239 | } 240 | } 241 | ); 242 | 243 | server.tool( 244 | "vim_register", 245 | "Manage Neovim register contents", 246 | { 247 | register: z.string().regex(/^[a-z"]$/).describe("Register name - a lowercase letter [a-z] or double-quote [\"] for the unnamed register"), 248 | content: z.string().describe("The text content to store in the specified register") 249 | }, 250 | async ({ register, content }) => { 251 | try { 252 | const result = await neovimManager.setRegister(register, content); 253 | return { 254 | content: [{ 255 | type: "text", 256 | text: result 257 | }] 258 | }; 259 | } catch (error) { 260 | return { 261 | content: [{ 262 | type: "text", 263 | text: error instanceof Error ? error.message : 'Error setting register' 264 | }] 265 | }; 266 | } 267 | } 268 | ); 269 | 270 | server.tool( 271 | "vim_visual", 272 | "Create visual mode selections in the buffer", 273 | { 274 | startLine: z.number().describe("The starting line number for visual selection (1-indexed)"), 275 | startColumn: z.number().describe("The starting column number for visual selection (0-indexed)"), 276 | endLine: z.number().describe("The ending line number for visual selection (1-indexed)"), 277 | endColumn: z.number().describe("The ending column number for visual selection (0-indexed)") 278 | }, 279 | async ({ startLine, startColumn, endLine, endColumn }) => { 280 | try { 281 | const result = await neovimManager.visualSelect(startLine, startColumn, endLine, endColumn); 282 | return { 283 | content: [{ 284 | type: "text", 285 | text: result 286 | }] 287 | }; 288 | } catch (error) { 289 | return { 290 | content: [{ 291 | type: "text", 292 | text: error instanceof Error ? error.message : 'Error creating visual selection' 293 | }] 294 | }; 295 | } 296 | } 297 | ); 298 | 299 | // New enhanced buffer management tools 300 | server.tool( 301 | "vim_buffer_switch", 302 | "Switch between buffers by name or number", 303 | { 304 | identifier: z.union([z.string(), z.number()]).describe("Buffer identifier - can be buffer number or filename/path") 305 | }, 306 | async ({ identifier }) => { 307 | try { 308 | const result = await neovimManager.switchBuffer(identifier); 309 | return { 310 | content: [{ 311 | type: "text", 312 | text: result 313 | }] 314 | }; 315 | } catch (error) { 316 | return { 317 | content: [{ 318 | type: "text", 319 | text: error instanceof Error ? error.message : 'Error switching buffer' 320 | }] 321 | }; 322 | } 323 | } 324 | ); 325 | 326 | server.tool( 327 | "vim_buffer_save", 328 | "Save current buffer or save to specific filename", 329 | { 330 | filename: z.string().optional().describe("Optional filename to save buffer to (defaults to current buffer's filename)") 331 | }, 332 | async ({ filename }) => { 333 | try { 334 | const result = await neovimManager.saveBuffer(filename); 335 | return { 336 | content: [{ 337 | type: "text", 338 | text: result 339 | }] 340 | }; 341 | } catch (error) { 342 | return { 343 | content: [{ 344 | type: "text", 345 | text: error instanceof Error ? error.message : 'Error saving buffer' 346 | }] 347 | }; 348 | } 349 | } 350 | ); 351 | 352 | server.tool( 353 | "vim_file_open", 354 | "Open files into new buffers", 355 | { 356 | filename: z.string().describe("Path to the file to open") 357 | }, 358 | async ({ filename }) => { 359 | try { 360 | const result = await neovimManager.openFile(filename); 361 | return { 362 | content: [{ 363 | type: "text", 364 | text: result 365 | }] 366 | }; 367 | } catch (error) { 368 | return { 369 | content: [{ 370 | type: "text", 371 | text: error instanceof Error ? error.message : 'Error opening file' 372 | }] 373 | }; 374 | } 375 | } 376 | ); 377 | 378 | // New search and replace tools 379 | server.tool( 380 | "vim_search", 381 | "Search within current buffer with regex support and options", 382 | { 383 | pattern: z.string().describe("Search pattern (supports regex)"), 384 | ignoreCase: z.boolean().optional().describe("Whether to ignore case in search (default: false)"), 385 | wholeWord: z.boolean().optional().describe("Whether to match whole words only (default: false)") 386 | }, 387 | async ({ pattern, ignoreCase = false, wholeWord = false }) => { 388 | try { 389 | const result = await neovimManager.searchInBuffer(pattern, { ignoreCase, wholeWord }); 390 | return { 391 | content: [{ 392 | type: "text", 393 | text: result 394 | }] 395 | }; 396 | } catch (error) { 397 | return { 398 | content: [{ 399 | type: "text", 400 | text: error instanceof Error ? error.message : 'Error searching in buffer' 401 | }] 402 | }; 403 | } 404 | } 405 | ); 406 | 407 | server.tool( 408 | "vim_search_replace", 409 | "Find and replace with global, case-insensitive, and confirm options", 410 | { 411 | pattern: z.string().describe("Search pattern (supports regex)"), 412 | replacement: z.string().describe("Replacement text"), 413 | global: z.boolean().optional().describe("Replace all occurrences in each line (default: false)"), 414 | ignoreCase: z.boolean().optional().describe("Whether to ignore case in search (default: false)"), 415 | confirm: z.boolean().optional().describe("Whether to confirm each replacement (default: false)") 416 | }, 417 | async ({ pattern, replacement, global = false, ignoreCase = false, confirm = false }) => { 418 | try { 419 | const result = await neovimManager.searchAndReplace(pattern, replacement, { global, ignoreCase, confirm }); 420 | return { 421 | content: [{ 422 | type: "text", 423 | text: result 424 | }] 425 | }; 426 | } catch (error) { 427 | return { 428 | content: [{ 429 | type: "text", 430 | text: error instanceof Error ? error.message : 'Error in search and replace' 431 | }] 432 | }; 433 | } 434 | } 435 | ); 436 | 437 | server.tool( 438 | "vim_grep", 439 | "Project-wide search using vimgrep with quickfix list", 440 | { 441 | pattern: z.string().describe("Search pattern to grep for"), 442 | filePattern: z.string().optional().describe("File pattern to search in (default: **/* for all files)") 443 | }, 444 | async ({ pattern, filePattern = "**/*" }) => { 445 | try { 446 | const result = await neovimManager.grepInProject(pattern, filePattern); 447 | return { 448 | content: [{ 449 | type: "text", 450 | text: result 451 | }] 452 | }; 453 | } catch (error) { 454 | return { 455 | content: [{ 456 | type: "text", 457 | text: error instanceof Error ? error.message : 'Error in grep search' 458 | }] 459 | }; 460 | } 461 | } 462 | ); 463 | 464 | // Health check tool 465 | server.tool( 466 | "vim_health", 467 | "Check Neovim connection health", 468 | {}, 469 | async () => { 470 | const isHealthy = await neovimManager.healthCheck(); 471 | return { 472 | content: [{ 473 | type: "text", 474 | text: isHealthy ? "Neovim connection is healthy" : "Neovim connection failed" 475 | }] 476 | }; 477 | } 478 | ); 479 | 480 | // Macro management tool 481 | server.tool( 482 | "vim_macro", 483 | "Record, stop, and play Neovim macros", 484 | { 485 | action: z.enum(["record", "stop", "play"]).describe("Action to perform with macros"), 486 | register: z.string().optional().describe("Register to record/play macro (a-z, required for record/play)"), 487 | count: z.number().optional().describe("Number of times to play macro (default: 1)") 488 | }, 489 | async ({ action, register, count = 1 }) => { 490 | try { 491 | const result = await neovimManager.manageMacro(action, register, count); 492 | return { 493 | content: [{ 494 | type: "text", 495 | text: result 496 | }] 497 | }; 498 | } catch (error) { 499 | return { 500 | content: [{ 501 | type: "text", 502 | text: error instanceof Error ? error.message : 'Error managing macro' 503 | }] 504 | }; 505 | } 506 | } 507 | ); 508 | 509 | // Tab management tool 510 | server.tool( 511 | "vim_tab", 512 | "Manage Neovim tabs: create, close, and navigate between tabs", 513 | { 514 | action: z.enum(["new", "close", "next", "prev", "first", "last", "list"]).describe("Tab action to perform"), 515 | filename: z.string().optional().describe("Filename for new tab (optional)") 516 | }, 517 | async ({ action, filename }) => { 518 | try { 519 | const result = await neovimManager.manageTab(action, filename); 520 | return { 521 | content: [{ 522 | type: "text", 523 | text: result 524 | }] 525 | }; 526 | } catch (error) { 527 | return { 528 | content: [{ 529 | type: "text", 530 | text: error instanceof Error ? error.message : 'Error managing tab' 531 | }] 532 | }; 533 | } 534 | } 535 | ); 536 | 537 | // Code folding tool 538 | server.tool( 539 | "vim_fold", 540 | "Manage code folding: create, open, close, and toggle folds", 541 | { 542 | action: z.enum(["create", "open", "close", "toggle", "openall", "closeall", "delete"]).describe("Folding action to perform"), 543 | startLine: z.number().optional().describe("Start line for creating fold (required for create)"), 544 | endLine: z.number().optional().describe("End line for creating fold (required for create)") 545 | }, 546 | async ({ action, startLine, endLine }) => { 547 | try { 548 | const result = await neovimManager.manageFold(action, startLine, endLine); 549 | return { 550 | content: [{ 551 | type: "text", 552 | text: result 553 | }] 554 | }; 555 | } catch (error) { 556 | return { 557 | content: [{ 558 | type: "text", 559 | text: error instanceof Error ? error.message : 'Error managing fold' 560 | }] 561 | }; 562 | } 563 | } 564 | ); 565 | 566 | // Jump list navigation tool 567 | server.tool( 568 | "vim_jump", 569 | "Navigate Neovim jump list: go back, forward, or list jumps", 570 | { 571 | direction: z.enum(["back", "forward", "list"]).describe("Jump direction or list jumps") 572 | }, 573 | async ({ direction }) => { 574 | try { 575 | const result = await neovimManager.navigateJumpList(direction); 576 | return { 577 | content: [{ 578 | type: "text", 579 | text: result 580 | }] 581 | }; 582 | } catch (error) { 583 | return { 584 | content: [{ 585 | type: "text", 586 | text: error instanceof Error ? error.message : 'Error navigating jump list' 587 | }] 588 | }; 589 | } 590 | } 591 | ); 592 | 593 | // Register a sample prompt for Neovim workflow assistance 594 | server.prompt( 595 | "neovim_workflow", 596 | "Get help with common Neovim workflows and editing tasks", 597 | { 598 | task: z.enum(["editing", "navigation", "search", "buffers", "windows", "macros"]).describe("Type of Neovim task you need help with") 599 | }, 600 | async ({ task }) => { 601 | const workflows = { 602 | 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", 603 | 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", 604 | 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", 605 | 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", 606 | 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", 607 | 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" 608 | }; 609 | 610 | return { 611 | messages: [ 612 | { 613 | role: "assistant", 614 | content: { 615 | type: "text", 616 | text: workflows[task] || "Unknown task type. Available tasks: editing, navigation, search, buffers, windows, macros" 617 | } 618 | } 619 | ] 620 | }; 621 | } 622 | ); 623 | 624 | /** 625 | * Start the server using stdio transport. 626 | * This allows the server to communicate via standard input/output streams. 627 | */ 628 | async function main() { 629 | const transport = new StdioServerTransport(); 630 | await server.connect(transport); 631 | } 632 | 633 | main().catch((error) => { 634 | console.error("Server error:", error); 635 | process.exit(1); 636 | }); 637 | ``` -------------------------------------------------------------------------------- /src/neovim.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { attach, Neovim } from 'neovim'; 2 | 3 | export class NeovimConnectionError extends Error { 4 | constructor(socketPath: string, cause?: Error) { 5 | super(`Failed to connect to Neovim at ${socketPath}. Is Neovim running with --listen ${socketPath}?`); 6 | this.name = 'NeovimConnectionError'; 7 | this.cause = cause; 8 | } 9 | } 10 | 11 | export class NeovimCommandError extends Error { 12 | constructor(command: string, originalError: string) { 13 | super(`Failed to execute command '${command}': ${originalError}`); 14 | this.name = 'NeovimCommandError'; 15 | } 16 | } 17 | 18 | export class NeovimValidationError extends Error { 19 | constructor(message: string) { 20 | super(message); 21 | this.name = 'NeovimValidationError'; 22 | } 23 | } 24 | 25 | interface NeovimStatus { 26 | cursorPosition: [number, number]; 27 | mode: string; 28 | visualSelection: string; 29 | fileName: string; 30 | windowLayout: string; 31 | currentTab: number; 32 | marks: { [key: string]: [number, number] }; 33 | registers: { [key: string]: string }; 34 | cwd: string; 35 | lspInfo?: string; 36 | pluginInfo?: string; 37 | visualInfo?: { 38 | hasActiveSelection: boolean; 39 | visualModeType?: string; 40 | startPos?: [number, number]; 41 | endPos?: [number, number]; 42 | lastVisualStart?: [number, number]; 43 | lastVisualEnd?: [number, number]; 44 | }; 45 | } 46 | 47 | interface BufferInfo { 48 | number: number; 49 | name: string; 50 | isListed: boolean; 51 | isLoaded: boolean; 52 | modified: boolean; 53 | syntax: string; 54 | windowIds: number[]; 55 | } 56 | 57 | interface WindowInfo { 58 | id: number; 59 | bufferId: number; 60 | width: number; 61 | height: number; 62 | row: number; 63 | col: number; 64 | } 65 | 66 | export class NeovimManager { 67 | private static instance: NeovimManager; 68 | 69 | private constructor() { } 70 | 71 | public static getInstance(): NeovimManager { 72 | if (!NeovimManager.instance) { 73 | NeovimManager.instance = new NeovimManager(); 74 | } 75 | return NeovimManager.instance; 76 | } 77 | 78 | public async healthCheck(): Promise<boolean> { 79 | try { 80 | const nvim = await this.connect(); 81 | await nvim.eval('1'); // Simple test 82 | return true; 83 | } catch { 84 | return false; 85 | } 86 | } 87 | 88 | private validateSocketPath(path: string): void { 89 | if (!path || path.trim().length === 0) { 90 | throw new NeovimValidationError('Socket path cannot be empty'); 91 | } 92 | } 93 | 94 | private async connect(): Promise<Neovim> { 95 | const socketPath = process.env.NVIM_SOCKET_PATH || '/tmp/nvim'; 96 | this.validateSocketPath(socketPath); 97 | 98 | try { 99 | return attach({ 100 | socket: socketPath 101 | }); 102 | } catch (error) { 103 | console.error('Error connecting to Neovim:', error); 104 | throw new NeovimConnectionError(socketPath, error as Error); 105 | } 106 | } 107 | 108 | public async getBufferContents(filename?: string): Promise<Map<number, string>> { 109 | try { 110 | const nvim = await this.connect(); 111 | let buffer; 112 | 113 | if (filename) { 114 | // Find buffer by filename 115 | const buffers = await nvim.buffers; 116 | let targetBuffer = null; 117 | 118 | for (const buf of buffers) { 119 | const bufName = await buf.name; 120 | if (bufName === filename || bufName.endsWith(filename)) { 121 | targetBuffer = buf; 122 | break; 123 | } 124 | } 125 | 126 | if (!targetBuffer) { 127 | throw new NeovimValidationError(`Buffer not found: ${filename}`); 128 | } 129 | buffer = targetBuffer; 130 | } else { 131 | buffer = await nvim.buffer; 132 | } 133 | 134 | const lines = await buffer.lines; 135 | const lineMap = new Map<number, string>(); 136 | 137 | lines.forEach((line: string, index: number) => { 138 | lineMap.set(index + 1, line); 139 | }); 140 | 141 | return lineMap; 142 | } catch (error) { 143 | if (error instanceof NeovimValidationError) { 144 | throw error; 145 | } 146 | console.error('Error getting buffer contents:', error); 147 | return new Map(); 148 | } 149 | } 150 | 151 | public async sendCommand(command: string): Promise<string> { 152 | if (!command || command.trim().length === 0) { 153 | throw new NeovimValidationError('Command cannot be empty'); 154 | } 155 | 156 | try { 157 | const nvim = await this.connect(); 158 | 159 | // Remove leading colon if present 160 | const normalizedCommand = command.startsWith(':') ? command.substring(1) : command; 161 | 162 | // Handle shell commands (starting with !) 163 | if (normalizedCommand.startsWith('!')) { 164 | if (process.env.ALLOW_SHELL_COMMANDS !== 'true') { 165 | return 'Shell command execution is disabled. Set ALLOW_SHELL_COMMANDS=true environment variable to enable shell commands.'; 166 | } 167 | 168 | const shellCommand = normalizedCommand.substring(1).trim(); 169 | if (!shellCommand) { 170 | throw new NeovimValidationError('Shell command cannot be empty'); 171 | } 172 | 173 | try { 174 | // Execute the command and capture output directly 175 | const output = await nvim.eval(`system('${shellCommand.replace(/'/g, "''")}')`); 176 | if (output) { 177 | return String(output).trim(); 178 | } 179 | return 'No output from command'; 180 | } catch (error) { 181 | console.error('Shell command error:', error); 182 | const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; 183 | throw new NeovimCommandError(`!${shellCommand}`, errorMessage); 184 | } 185 | } 186 | 187 | // For regular Vim commands 188 | await nvim.setVvar('errmsg', ''); 189 | 190 | // Execute the command and capture its output using the execute() function 191 | const output = await nvim.call('execute', [normalizedCommand]); 192 | 193 | // Check for errors 194 | const vimerr = await nvim.getVvar('errmsg'); 195 | if (vimerr) { 196 | console.error('Vim error:', vimerr); 197 | throw new NeovimCommandError(normalizedCommand, String(vimerr)); 198 | } 199 | 200 | // Return the actual command output if any 201 | return output ? String(output).trim() : 'Command executed (no output)'; 202 | } catch (error) { 203 | if (error instanceof NeovimCommandError || error instanceof NeovimValidationError) { 204 | throw error; 205 | } 206 | console.error('Error sending command:', error); 207 | throw new NeovimCommandError(command, error instanceof Error ? error.message : 'Unknown error'); 208 | } 209 | } 210 | 211 | private async getVisualSelectionInfo(nvim: Neovim, mode: string): Promise<{ 212 | hasSelection: boolean; 213 | selectedText?: string; 214 | startPos?: [number, number]; 215 | endPos?: [number, number]; 216 | visualModeType?: string; 217 | lastVisualStart?: [number, number]; 218 | lastVisualEnd?: [number, number]; 219 | }> { 220 | try { 221 | const isInVisualMode = mode.includes('v') || mode.includes('V') || mode.includes('\x16'); 222 | 223 | if (isInVisualMode) { 224 | // Currently in visual mode - get active selection 225 | const [startPos, endPos, initialVisualModeType] = await Promise.all([ 226 | nvim.call('getpos', ['v']) as Promise<[number, number, number, number]>, 227 | nvim.call('getpos', ['.']) as Promise<[number, number, number, number]>, 228 | nvim.call('visualmode', []) as Promise<string> 229 | ]); 230 | 231 | // Convert positions to [line, col] format 232 | const start: [number, number] = [startPos[1], startPos[2]]; 233 | const end: [number, number] = [endPos[1], endPos[2]]; 234 | 235 | // Get the selected text using a more reliable approach 236 | let selectedText = ''; 237 | let visualModeType = initialVisualModeType; 238 | try { 239 | const result = await nvim.lua(` 240 | -- Get visual mode type first 241 | local mode = vim.fn.visualmode() 242 | if not mode or mode == '' then 243 | return { text = '', mode = '' } 244 | end 245 | 246 | local start_pos = vim.fn.getpos('v') 247 | local end_pos = vim.fn.getpos('.') 248 | local start_line, start_col = start_pos[2], start_pos[3] 249 | local end_line, end_col = end_pos[2], end_pos[3] 250 | 251 | -- Ensure proper ordering (start should be before end) 252 | if start_line > end_line or (start_line == end_line and start_col > end_col) then 253 | start_line, end_line = end_line, start_line 254 | start_col, end_col = end_col, start_col 255 | end 256 | 257 | local text = '' 258 | 259 | if mode == 'v' then 260 | -- Character-wise visual mode 261 | if start_line == end_line then 262 | local line = vim.api.nvim_buf_get_lines(0, start_line - 1, start_line, false)[1] or '' 263 | text = line:sub(start_col, end_col) 264 | else 265 | local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) 266 | if #lines > 0 then 267 | -- Handle first line 268 | lines[1] = lines[1]:sub(start_col) 269 | -- Handle last line 270 | if #lines > 1 then 271 | lines[#lines] = lines[#lines]:sub(1, end_col) 272 | end 273 | text = table.concat(lines, '\\n') 274 | end 275 | end 276 | elseif mode == 'V' then 277 | -- Line-wise visual mode 278 | local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) 279 | text = table.concat(lines, '\\n') 280 | elseif mode == '\\022' then 281 | -- Block-wise visual mode (Ctrl-V) 282 | local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) 283 | local result = {} 284 | for _, line in ipairs(lines) do 285 | table.insert(result, line:sub(start_col, end_col)) 286 | end 287 | text = table.concat(result, '\\n') 288 | end 289 | 290 | return { text = text, mode = mode } 291 | `) as { text: string; mode: string }; 292 | 293 | selectedText = result.text || ''; 294 | visualModeType = result.mode || visualModeType; 295 | } catch (e) { 296 | selectedText = '[Selection text unavailable]'; 297 | } 298 | 299 | return { 300 | hasSelection: true, 301 | selectedText, 302 | startPos: start, 303 | endPos: end, 304 | visualModeType 305 | }; 306 | } else { 307 | // Not in visual mode - get last visual selection marks 308 | try { 309 | const [lastStart, lastEnd] = await Promise.all([ 310 | nvim.call('getpos', ["'<"]) as Promise<[number, number, number, number]>, 311 | nvim.call('getpos', ["'>"]) as Promise<[number, number, number, number]> 312 | ]); 313 | 314 | return { 315 | hasSelection: false, 316 | lastVisualStart: [lastStart[1], lastStart[2]], 317 | lastVisualEnd: [lastEnd[1], lastEnd[2]] 318 | }; 319 | } catch (e) { 320 | return { hasSelection: false }; 321 | } 322 | } 323 | } catch (error) { 324 | return { hasSelection: false, selectedText: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }; 325 | } 326 | } 327 | 328 | public async getNeovimStatus(): Promise<NeovimStatus | string> { 329 | try { 330 | const nvim = await this.connect(); 331 | const window = await nvim.window; 332 | const cursor = await window.cursor; 333 | const mode = await nvim.mode; 334 | const buffer = await nvim.buffer; 335 | 336 | // Get window layout 337 | const layout = await nvim.eval('winlayout()'); 338 | const tabpage = await nvim.tabpage; 339 | const currentTab = await tabpage.number; 340 | 341 | // Get marks (a-z) - only include set marks 342 | const marks: { [key: string]: [number, number] } = {}; 343 | for (const mark of 'abcdefghijklmnopqrstuvwxyz') { 344 | try { 345 | const pos = await nvim.eval(`getpos("'${mark}")`) as [number, number, number, number]; 346 | // Only include marks that are actually set (not at position 0,0) 347 | if (pos[1] > 0 && pos[2] > 0) { 348 | marks[mark] = [pos[1], pos[2]]; 349 | } 350 | } catch (e) { 351 | // Mark not set 352 | } 353 | } 354 | 355 | // Get registers (a-z, ", 0-9) - only include non-empty registers 356 | const registers: { [key: string]: string } = {}; 357 | const registerNames = [...'abcdefghijklmnopqrstuvwxyz', '"', ...Array(10).keys()]; 358 | for (const reg of registerNames) { 359 | try { 360 | const content = String(await nvim.eval(`getreg('${reg}')`)); 361 | // Only include registers that have content 362 | if (content && content.trim().length > 0) { 363 | registers[String(reg)] = content; 364 | } 365 | } catch (e) { 366 | // Register empty or error 367 | } 368 | } 369 | 370 | // Get current working directory 371 | const cwd = await nvim.call('getcwd'); 372 | 373 | // Get basic plugin information (LSP clients, loaded plugins) 374 | let lspInfo = ''; 375 | let pluginInfo = ''; 376 | 377 | try { 378 | // Get LSP clients if available (use new API for Neovim >=0.10) 379 | const lspClients = await nvim.eval('luaeval("vim.lsp.get_clients()")'); 380 | if (Array.isArray(lspClients) && lspClients.length > 0) { 381 | const clientNames = lspClients.map((client: any) => client.name || 'unknown').join(', '); 382 | lspInfo = `Active LSP clients: ${clientNames}`; 383 | } else { 384 | lspInfo = 'No active LSP clients'; 385 | } 386 | } catch (e) { 387 | lspInfo = 'LSP information unavailable'; 388 | } 389 | 390 | try { 391 | // Get loaded plugins (simplified check) 392 | const hasLsp = await nvim.eval('exists(":LspInfo")'); 393 | const hasTelescope = await nvim.eval('exists(":Telescope")'); 394 | const hasTreesitter = await nvim.eval('exists("g:loaded_nvim_treesitter")'); 395 | const hasCompletion = await nvim.eval('exists("g:loaded_completion")'); 396 | 397 | const plugins = []; 398 | if (hasLsp) plugins.push('LSP'); 399 | if (hasTelescope) plugins.push('Telescope'); 400 | if (hasTreesitter) plugins.push('TreeSitter'); 401 | if (hasCompletion) plugins.push('Completion'); 402 | 403 | pluginInfo = plugins.length > 0 ? `Detected plugins: ${plugins.join(', ')}` : 'No common plugins detected'; 404 | } catch (e) { 405 | pluginInfo = 'Plugin information unavailable'; 406 | } 407 | 408 | // Get visual selection information using the new method 409 | const visualInfo = await this.getVisualSelectionInfo(nvim, mode.mode); 410 | 411 | const neovimStatus: NeovimStatus = { 412 | cursorPosition: cursor, 413 | mode: mode.mode, 414 | visualSelection: visualInfo.selectedText || '', 415 | fileName: await buffer.name, 416 | windowLayout: JSON.stringify(layout), 417 | currentTab, 418 | marks, 419 | registers, 420 | cwd, 421 | lspInfo, 422 | pluginInfo, 423 | visualInfo: { 424 | hasActiveSelection: visualInfo.hasSelection, 425 | visualModeType: visualInfo.visualModeType, 426 | startPos: visualInfo.startPos, 427 | endPos: visualInfo.endPos, 428 | lastVisualStart: visualInfo.lastVisualStart, 429 | lastVisualEnd: visualInfo.lastVisualEnd 430 | } 431 | }; 432 | 433 | return neovimStatus; 434 | } catch (error) { 435 | console.error('Error getting Neovim status:', error); 436 | return 'Error getting Neovim status'; 437 | } 438 | } 439 | 440 | public async editLines(startLine: number, mode: 'replace' | 'insert' | 'replaceAll', newText: string): Promise<string> { 441 | try { 442 | const nvim = await this.connect(); 443 | const splitByLines = newText.split('\n'); 444 | const buffer = await nvim.buffer; 445 | 446 | if (mode === 'replaceAll') { 447 | // Handle full buffer replacement 448 | const lineCount = await buffer.length; 449 | // Delete all lines and then append new content 450 | await buffer.remove(0, lineCount, true); 451 | await buffer.insert(splitByLines, 0); 452 | return 'Buffer completely replaced'; 453 | } else if (mode === 'replace') { 454 | await buffer.replace(splitByLines, startLine - 1); 455 | return 'Lines replaced successfully'; 456 | } else if (mode === 'insert') { 457 | await buffer.insert(splitByLines, startLine - 1); 458 | return 'Lines inserted successfully'; 459 | } 460 | 461 | return 'Invalid mode specified'; 462 | } catch (error) { 463 | console.error('Error editing lines:', error); 464 | return 'Error editing lines'; 465 | } 466 | } 467 | 468 | public async getWindows(): Promise<WindowInfo[]> { 469 | try { 470 | const nvim = await this.connect(); 471 | const windows = await nvim.windows; 472 | const windowInfos: WindowInfo[] = []; 473 | 474 | for (const win of windows) { 475 | const buffer = await win.buffer; 476 | const [width, height] = await Promise.all([ 477 | win.width, 478 | win.height 479 | ]); 480 | const position = await win.position; 481 | 482 | windowInfos.push({ 483 | id: win.id, 484 | bufferId: buffer.id, 485 | width, 486 | height, 487 | row: position[0], 488 | col: position[1] 489 | }); 490 | } 491 | 492 | return windowInfos; 493 | } catch (error) { 494 | console.error('Error getting windows:', error); 495 | return []; 496 | } 497 | } 498 | 499 | public async manipulateWindow(command: string): Promise<string> { 500 | const validCommands = ['split', 'vsplit', 'only', 'close', 'wincmd h', 'wincmd j', 'wincmd k', 'wincmd l']; 501 | if (!validCommands.some(cmd => command.startsWith(cmd))) { 502 | return 'Invalid window command'; 503 | } 504 | 505 | try { 506 | const nvim = await this.connect(); 507 | await nvim.command(command); 508 | return 'Window command executed'; 509 | } catch (error) { 510 | console.error('Error manipulating window:', error); 511 | return 'Error executing window command'; 512 | } 513 | } 514 | 515 | public async setMark(mark: string, line: number, col: number): Promise<string> { 516 | if (!/^[a-z]$/.test(mark)) { 517 | return 'Invalid mark name (must be a-z)'; 518 | } 519 | 520 | try { 521 | const nvim = await this.connect(); 522 | await nvim.command(`mark ${mark}`); 523 | const window = await nvim.window; 524 | await (window.cursor = [line, col]); 525 | return `Mark ${mark} set at line ${line}, column ${col}`; 526 | } catch (error) { 527 | console.error('Error setting mark:', error); 528 | return 'Error setting mark'; 529 | } 530 | } 531 | 532 | public async setRegister(register: string, content: string): Promise<string> { 533 | const validRegisters = [...'abcdefghijklmnopqrstuvwxyz"']; 534 | if (!validRegisters.includes(register)) { 535 | return 'Invalid register name'; 536 | } 537 | 538 | try { 539 | const nvim = await this.connect(); 540 | await nvim.eval(`setreg('${register}', '${content.replace(/'/g, "''")}')`); 541 | return `Register ${register} set`; 542 | } catch (error) { 543 | console.error('Error setting register:', error); 544 | return 'Error setting register'; 545 | } 546 | } 547 | 548 | public async visualSelect(startLine: number, startCol: number, endLine: number, endCol: number): Promise<string> { 549 | try { 550 | const nvim = await this.connect(); 551 | const window = await nvim.window; 552 | 553 | // Enter visual mode 554 | await nvim.command('normal! v'); 555 | 556 | // Move cursor to start position 557 | await (window.cursor = [startLine, startCol]); 558 | 559 | // Move cursor to end position (selection will be made) 560 | await (window.cursor = [endLine, endCol]); 561 | 562 | return 'Visual selection made'; 563 | } catch (error) { 564 | console.error('Error making visual selection:', error); 565 | return 'Error making visual selection'; 566 | } 567 | } 568 | 569 | public async switchBuffer(identifier: string | number): Promise<string> { 570 | try { 571 | const nvim = await this.connect(); 572 | 573 | // If identifier is a number, switch by buffer number 574 | if (typeof identifier === 'number') { 575 | await nvim.command(`buffer ${identifier}`); 576 | return `Switched to buffer ${identifier}`; 577 | } 578 | 579 | // If identifier is a string, try to find buffer by name 580 | const buffers = await nvim.buffers; 581 | for (const buffer of buffers) { 582 | const bufName = await buffer.name; 583 | if (bufName === identifier || bufName.endsWith(identifier)) { 584 | await nvim.command(`buffer ${buffer.id}`); 585 | return `Switched to buffer: ${bufName}`; 586 | } 587 | } 588 | 589 | throw new NeovimValidationError(`Buffer not found: ${identifier}`); 590 | } catch (error) { 591 | if (error instanceof NeovimValidationError) { 592 | throw error; 593 | } 594 | console.error('Error switching buffer:', error); 595 | throw new NeovimCommandError(`buffer switch to ${identifier}`, error instanceof Error ? error.message : 'Unknown error'); 596 | } 597 | } 598 | 599 | public async saveBuffer(filename?: string): Promise<string> { 600 | try { 601 | const nvim = await this.connect(); 602 | 603 | if (filename) { 604 | // Save with specific filename 605 | await nvim.command(`write ${filename}`); 606 | return `Buffer saved to: ${filename}`; 607 | } else { 608 | // Save current buffer 609 | const buffer = await nvim.buffer; 610 | const bufferName = await buffer.name; 611 | 612 | if (!bufferName) { 613 | throw new NeovimValidationError('Cannot save unnamed buffer without specifying filename'); 614 | } 615 | 616 | await nvim.command('write'); 617 | return `Buffer saved: ${bufferName}`; 618 | } 619 | } catch (error) { 620 | if (error instanceof NeovimValidationError) { 621 | throw error; 622 | } 623 | console.error('Error saving buffer:', error); 624 | throw new NeovimCommandError(`save ${filename || 'current buffer'}`, error instanceof Error ? error.message : 'Unknown error'); 625 | } 626 | } 627 | 628 | public async openFile(filename: string): Promise<string> { 629 | if (!filename || filename.trim().length === 0) { 630 | throw new NeovimValidationError('Filename cannot be empty'); 631 | } 632 | 633 | try { 634 | const nvim = await this.connect(); 635 | await nvim.command(`edit ${filename}`); 636 | return `Opened file: ${filename}`; 637 | } catch (error) { 638 | console.error('Error opening file:', error); 639 | throw new NeovimCommandError(`edit ${filename}`, error instanceof Error ? error.message : 'Unknown error'); 640 | } 641 | } 642 | 643 | public async searchInBuffer(pattern: string, options: { ignoreCase?: boolean; wholeWord?: boolean } = {}): Promise<string> { 644 | if (!pattern || pattern.trim().length === 0) { 645 | throw new NeovimValidationError('Search pattern cannot be empty'); 646 | } 647 | 648 | try { 649 | const nvim = await this.connect(); 650 | 651 | // Build search command with options 652 | let searchPattern = pattern; 653 | if (options.wholeWord) { 654 | searchPattern = `\\<${pattern}\\>`; 655 | } 656 | 657 | // Set search options 658 | if (options.ignoreCase) { 659 | await nvim.command('set ignorecase'); 660 | } else { 661 | await nvim.command('set noignorecase'); 662 | } 663 | 664 | // Perform search and get matches 665 | const matches = await nvim.eval(`searchcount({"pattern": "${searchPattern.replace(/"/g, '\\"')}", "maxcount": 100})`); 666 | const matchInfo = matches as { current: number; total: number; maxcount: number; incomplete: number }; 667 | 668 | if (matchInfo.total === 0) { 669 | return `No matches found for: ${pattern}`; 670 | } 671 | 672 | // Move to first match 673 | await nvim.command(`/${searchPattern}`); 674 | 675 | return `Found ${matchInfo.total} matches for: ${pattern}${matchInfo.incomplete ? ' (showing first 100)' : ''}`; 676 | } catch (error) { 677 | console.error('Error searching in buffer:', error); 678 | throw new NeovimCommandError(`search for ${pattern}`, error instanceof Error ? error.message : 'Unknown error'); 679 | } 680 | } 681 | 682 | public async searchAndReplace(pattern: string, replacement: string, options: { global?: boolean; ignoreCase?: boolean; confirm?: boolean } = {}): Promise<string> { 683 | if (!pattern || pattern.trim().length === 0) { 684 | throw new NeovimValidationError('Search pattern cannot be empty'); 685 | } 686 | 687 | try { 688 | const nvim = await this.connect(); 689 | 690 | // Build substitute command 691 | let flags = ''; 692 | if (options.global) flags += 'g'; 693 | if (options.ignoreCase) flags += 'i'; 694 | if (options.confirm) flags += 'c'; 695 | 696 | const command = `%s/${pattern.replace(/\//g, '\\/')}/${replacement.replace(/\//g, '\\/')}/${flags}`; 697 | 698 | const result = await nvim.call('execute', [command]); 699 | return result ? String(result).trim() : 'Search and replace completed'; 700 | } catch (error) { 701 | console.error('Error in search and replace:', error); 702 | throw new NeovimCommandError(`substitute ${pattern} -> ${replacement}`, error instanceof Error ? error.message : 'Unknown error'); 703 | } 704 | } 705 | 706 | public async grepInProject(pattern: string, filePattern: string = '**/*'): Promise<string> { 707 | if (!pattern || pattern.trim().length === 0) { 708 | throw new NeovimValidationError('Grep pattern cannot be empty'); 709 | } 710 | 711 | try { 712 | const nvim = await this.connect(); 713 | 714 | // Use vimgrep for internal searching 715 | const command = `vimgrep /${pattern}/ ${filePattern}`; 716 | await nvim.command(command); 717 | 718 | // Get quickfix list 719 | const qflist = await nvim.eval('getqflist()'); 720 | const results = qflist as Array<{ filename: string; lnum: number; text: string }>; 721 | 722 | if (results.length === 0) { 723 | return `No matches found for: ${pattern}`; 724 | } 725 | 726 | const summary = results.slice(0, 10).map(item => 727 | `${item.filename}:${item.lnum}: ${item.text.trim()}` 728 | ).join('\n'); 729 | 730 | const totalText = results.length > 10 ? `\n... and ${results.length - 10} more matches` : ''; 731 | return `Found ${results.length} matches for: ${pattern}\n${summary}${totalText}`; 732 | } catch (error) { 733 | console.error('Error in grep:', error); 734 | throw new NeovimCommandError(`grep ${pattern}`, error instanceof Error ? error.message : 'Unknown error'); 735 | } 736 | } 737 | 738 | public async getOpenBuffers(): Promise<BufferInfo[]> { 739 | try { 740 | const nvim = await this.connect(); 741 | const buffers = await nvim.buffers; 742 | const windows = await nvim.windows; 743 | const bufferInfos: BufferInfo[] = []; 744 | 745 | for (const buffer of buffers) { 746 | const [ 747 | isLoaded, 748 | isListedOption, 749 | modified, 750 | syntax 751 | ] = await Promise.all([ 752 | buffer.loaded, 753 | buffer.getOption('buflisted'), 754 | buffer.getOption('modified'), 755 | buffer.getOption('syntax') 756 | ]); 757 | const isListed = Boolean(isListedOption); 758 | 759 | // Find windows containing this buffer 760 | const windowIds = []; 761 | for (const win of windows) { 762 | const winBuffer = await win.buffer; 763 | if (winBuffer.id === buffer.id) { 764 | windowIds.push(win.id); 765 | } 766 | } 767 | 768 | bufferInfos.push({ 769 | number: buffer.id, 770 | name: await buffer.name, 771 | isListed, 772 | isLoaded, 773 | modified: Boolean(modified), 774 | syntax: String(syntax), 775 | windowIds 776 | }); 777 | } 778 | 779 | return bufferInfos; 780 | } catch (error) { 781 | console.error('Error getting open buffers:', error); 782 | return []; 783 | } 784 | } 785 | 786 | public async manageMacro(action: string, register?: string, count: number = 1): Promise<string> { 787 | try { 788 | const nvim = await this.connect(); 789 | 790 | switch (action) { 791 | case 'record': 792 | if (!register || register.length !== 1 || !/[a-z]/.test(register)) { 793 | throw new NeovimValidationError('Register must be a single letter a-z for recording'); 794 | } 795 | await nvim.input(`q${register}`); 796 | return `Started recording macro in register '${register}'`; 797 | 798 | case 'stop': 799 | await nvim.input('q'); 800 | return 'Stopped recording macro'; 801 | 802 | case 'play': 803 | if (!register || register.length !== 1 || !/[a-z]/.test(register)) { 804 | throw new NeovimValidationError('Register must be a single letter a-z for playing'); 805 | } 806 | const playCommand = count > 1 ? `${count}@${register}` : `@${register}`; 807 | await nvim.input(playCommand); 808 | return `Played macro from register '${register}' ${count} time(s)`; 809 | 810 | default: 811 | throw new NeovimValidationError(`Unknown macro action: ${action}`); 812 | } 813 | } catch (error) { 814 | if (error instanceof NeovimValidationError) { 815 | throw error; 816 | } 817 | console.error('Error managing macro:', error); 818 | throw new NeovimCommandError(`macro ${action}`, error instanceof Error ? error.message : 'Unknown error'); 819 | } 820 | } 821 | 822 | public async manageTab(action: string, filename?: string): Promise<string> { 823 | try { 824 | const nvim = await this.connect(); 825 | 826 | switch (action) { 827 | case 'new': 828 | if (filename) { 829 | await nvim.command(`tabnew ${filename}`); 830 | return `Created new tab with file: ${filename}`; 831 | } else { 832 | await nvim.command('tabnew'); 833 | return 'Created new empty tab'; 834 | } 835 | 836 | case 'close': 837 | await nvim.command('tabclose'); 838 | return 'Closed current tab'; 839 | 840 | case 'next': 841 | await nvim.command('tabnext'); 842 | return 'Moved to next tab'; 843 | 844 | case 'prev': 845 | await nvim.command('tabprev'); 846 | return 'Moved to previous tab'; 847 | 848 | case 'first': 849 | await nvim.command('tabfirst'); 850 | return 'Moved to first tab'; 851 | 852 | case 'last': 853 | await nvim.command('tablast'); 854 | return 'Moved to last tab'; 855 | 856 | case 'list': 857 | const tabs = await nvim.tabpages; 858 | const tabInfo = []; 859 | for (let i = 0; i < tabs.length; i++) { 860 | const tab = tabs[i]; 861 | const win = await tab.window; 862 | const buf = await win.buffer; 863 | const name = await buf.name; 864 | const current = await nvim.tabpage; 865 | const isCurrent = tab === current; 866 | tabInfo.push(`${isCurrent ? '*' : ' '}${i + 1}: ${name || '[No Name]'}`); 867 | } 868 | return `Tabs:\n${tabInfo.join('\n')}`; 869 | 870 | default: 871 | throw new NeovimValidationError(`Unknown tab action: ${action}`); 872 | } 873 | } catch (error) { 874 | if (error instanceof NeovimValidationError) { 875 | throw error; 876 | } 877 | console.error('Error managing tab:', error); 878 | throw new NeovimCommandError(`tab ${action}`, error instanceof Error ? error.message : 'Unknown error'); 879 | } 880 | } 881 | 882 | public async manageFold(action: string, startLine?: number, endLine?: number): Promise<string> { 883 | try { 884 | const nvim = await this.connect(); 885 | 886 | switch (action) { 887 | case 'create': 888 | if (startLine === undefined || endLine === undefined) { 889 | throw new NeovimValidationError('Start line and end line are required for creating folds'); 890 | } 891 | await nvim.command(`${startLine},${endLine}fold`); 892 | return `Created fold from line ${startLine} to ${endLine}`; 893 | 894 | case 'open': 895 | await nvim.input('zo'); 896 | return 'Opened fold at cursor'; 897 | 898 | case 'close': 899 | await nvim.input('zc'); 900 | return 'Closed fold at cursor'; 901 | 902 | case 'toggle': 903 | await nvim.input('za'); 904 | return 'Toggled fold at cursor'; 905 | 906 | case 'openall': 907 | await nvim.command('normal! zR'); 908 | return 'Opened all folds'; 909 | 910 | case 'closeall': 911 | await nvim.command('normal! zM'); 912 | return 'Closed all folds'; 913 | 914 | case 'delete': 915 | await nvim.input('zd'); 916 | return 'Deleted fold at cursor'; 917 | 918 | default: 919 | throw new NeovimValidationError(`Unknown fold action: ${action}`); 920 | } 921 | } catch (error) { 922 | if (error instanceof NeovimValidationError) { 923 | throw error; 924 | } 925 | console.error('Error managing fold:', error); 926 | throw new NeovimCommandError(`fold ${action}`, error instanceof Error ? error.message : 'Unknown error'); 927 | } 928 | } 929 | 930 | public async navigateJumpList(direction: string): Promise<string> { 931 | try { 932 | const nvim = await this.connect(); 933 | 934 | switch (direction) { 935 | case 'back': 936 | await nvim.input('\x0f'); // Ctrl-O 937 | return 'Jumped back in jump list'; 938 | 939 | case 'forward': 940 | await nvim.input('\x09'); // Ctrl-I (Tab) 941 | return 'Jumped forward in jump list'; 942 | 943 | case 'list': 944 | await nvim.command('jumps'); 945 | // Get the output from the command 946 | const output = await nvim.eval('execute("jumps")'); 947 | return `Jump list:\n${output}`; 948 | 949 | default: 950 | throw new NeovimValidationError(`Unknown jump direction: ${direction}`); 951 | } 952 | } catch (error) { 953 | if (error instanceof NeovimValidationError) { 954 | throw error; 955 | } 956 | console.error('Error navigating jump list:', error); 957 | throw new NeovimCommandError(`jump ${direction}`, error instanceof Error ? error.message : 'Unknown error'); 958 | } 959 | } 960 | } 961 | ```