Directory structure: └── coder-claudecode.nvim/ ├── README.md ├── ARCHITECTURE.md ├── CHANGELOG.md ├── CLAUDE.md ├── dev-config.lua ├── DEVELOPMENT.md ├── flake.lock ├── flake.nix ├── LICENSE ├── Makefile ├── PROTOCOL.md ├── STORY.md ├── .envrc ├── .luacheckrc ├── .stylua.toml ├── fixtures/ │ ├── nvim-aliases.sh │ ├── mini-files/ │ │ ├── init.lua │ │ ├── lazy-lock.json │ │ └── lua/ │ │ ├── config/ │ │ │ └── lazy.lua │ │ └── plugins/ │ │ ├── init.lua │ │ ├── mini-files.lua │ │ └── dev-claudecode.lua -> dev-config.lua │ ├── netrw/ │ │ ├── init.lua │ │ ├── lazy-lock.json │ │ └── lua/ │ │ ├── config/ │ │ │ ├── lazy.lua │ │ │ └── netrw.lua │ │ └── plugins/ │ │ ├── init.lua │ │ ├── netrw-keymaps.lua │ │ ├── snacks.lua │ │ └── dev-claudecode.lua -> dev-config.lua │ ├── nvim-tree/ │ │ ├── init.lua │ │ ├── lazy-lock.json │ │ └── lua/ │ │ ├── config/ │ │ │ └── lazy.lua │ │ └── plugins/ │ │ ├── init.lua │ │ ├── nvim-tree.lua │ │ └── dev-claudecode.lua -> dev-config.lua │ └── oil/ │ ├── init.lua │ ├── lazy-lock.json │ └── lua/ │ ├── config/ │ │ └── lazy.lua │ └── plugins/ │ ├── init.lua │ ├── oil-nvim.lua │ └── dev-claudecode.lua -> dev-config.lua ├── lua/ │ └── claudecode/ │ ├── config.lua │ ├── diff.lua │ ├── init.lua │ ├── integrations.lua │ ├── lockfile.lua │ ├── logger.lua │ ├── selection.lua │ ├── terminal.lua │ ├── utils.lua │ ├── visual_commands.lua │ ├── server/ │ │ ├── client.lua │ │ ├── frame.lua │ │ ├── handshake.lua │ │ ├── init.lua │ │ ├── mock.lua │ │ ├── tcp.lua │ │ └── utils.lua │ ├── terminal/ │ │ ├── native.lua │ │ └── snacks.lua │ └── tools/ │ ├── check_document_dirty.lua │ ├── close_all_diff_tabs.lua │ ├── close_tab.lua │ ├── get_current_selection.lua │ ├── get_diagnostics.lua │ ├── get_latest_selection.lua │ ├── get_open_editors.lua │ ├── get_workspace_folders.lua │ ├── init.lua │ ├── open_diff.lua │ ├── open_file.lua │ └── save_document.lua ├── plugin/ │ └── claudecode.lua ├── scripts/ │ ├── claude_interactive.sh │ ├── claude_shell_helpers.sh │ ├── lib_claude.sh │ ├── lib_ws_persistent.sh │ ├── manual_test_helper.lua │ ├── mcp_test.sh │ ├── research_messages.sh │ ├── run_integration_tests_individually.sh │ ├── test_neovim_websocket.sh │ ├── test_opendiff.lua │ ├── test_opendiff_simple.sh │ └── websocat.sh ├── tests/ │ ├── busted_setup.lua │ ├── config_test.lua │ ├── init.lua │ ├── lockfile_test.lua │ ├── minimal_init.lua │ ├── selection_test.lua │ ├── server_test.lua │ ├── simple_test.lua │ ├── helpers/ │ │ └── setup.lua │ ├── integration/ │ │ ├── basic_spec.lua │ │ ├── command_args_spec.lua │ │ └── mcp_tools_spec.lua │ ├── mocks/ │ │ └── vim.lua │ └── unit/ │ ├── at_mention_edge_cases_spec.lua │ ├── at_mention_spec.lua │ ├── claudecode_add_command_spec.lua │ ├── claudecode_send_command_spec.lua │ ├── config_spec.lua │ ├── diff_buffer_cleanup_spec.lua │ ├── diff_mcp_spec.lua │ ├── diff_spec.lua │ ├── directory_at_mention_spec.lua │ ├── init_spec.lua │ ├── logger_spec.lua │ ├── mini_files_integration_spec.lua │ ├── native_terminal_toggle_spec.lua │ ├── nvim_tree_visual_selection_spec.lua │ ├── oil_integration_spec.lua │ ├── opendiff_blocking_spec.lua │ ├── server_spec.lua │ ├── terminal_spec.lua │ ├── tools_spec.lua │ ├── visual_delay_timing_spec.lua │ ├── server/ │ │ └── handshake_spec.lua │ └── tools/ │ ├── check_document_dirty_spec.lua │ ├── close_all_diff_tabs_spec.lua │ ├── get_current_selection_spec.lua │ ├── get_diagnostics_spec.lua │ ├── get_latest_selection_spec.lua │ ├── get_open_editors_spec.lua │ ├── get_workspace_folders_spec.lua │ ├── open_diff_mcp_spec.lua │ ├── open_file_spec.lua │ └── save_document_spec.lua ├── .claude/ │ ├── settings.json │ └── hooks/ │ └── format.sh └── .github/ ├── ISSUE_TEMPLATE/ │ ├── bug_report.md │ └── feature_request.md └── workflows/ ├── claude.yml ├── test.yml └── update-changelog.yml ================================================ FILE: README.md ================================================ # claudecode.nvim [![Tests](https://github.com/coder/claudecode.nvim/actions/workflows/test.yml/badge.svg)](https://github.com/coder/claudecode.nvim/actions/workflows/test.yml) ![Neovim version](https://img.shields.io/badge/Neovim-0.8%2B-green) ![Status](https://img.shields.io/badge/Status-beta-blue) **The first Neovim IDE integration for Claude Code** — bringing Anthropic's AI coding assistant to your favorite editor with a pure Lua implementation. > 🎯 **TL;DR:** When Anthropic released Claude Code with VS Code and JetBrains support, I reverse-engineered their extension and built this Neovim plugin. This plugin implements the same WebSocket-based MCP protocol, giving Neovim users the same AI-powered coding experience. ## What Makes This Special When Anthropic released Claude Code, they only supported VS Code and JetBrains. As a Neovim user, I wanted the same experience — so I reverse-engineered their extension and built this. - 🚀 **Pure Lua, Zero Dependencies** — Built entirely with `vim.loop` and Neovim built-ins - 🔌 **100% Protocol Compatible** — Same WebSocket MCP implementation as official extensions - 🎓 **Fully Documented Protocol** — Learn how to build your own integrations ([see PROTOCOL.md](./PROTOCOL.md)) - ⚡ **First to Market** — Beat Anthropic to releasing Neovim support - 🛠️ **Built with AI** — Used Claude to reverse-engineer Claude's own protocol ## Installation ```lua { "coder/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, config = true, keys = { { "a", nil, desc = "AI/Claude Code" }, { "ac", "ClaudeCode", desc = "Toggle Claude" }, { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, { "am", "ClaudeCodeSelectModel", desc = "Select Claude model" }, { "ab", "ClaudeCodeAdd %", desc = "Add current buffer" }, { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, { "as", "ClaudeCodeTreeAdd", desc = "Add file", ft = { "NvimTree", "neo-tree", "oil", "minifiles" }, }, -- Diff management { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, { "ad", "ClaudeCodeDiffDeny", desc = "Deny diff" }, }, } ``` That's it! The plugin will auto-configure everything else. ## Requirements - Neovim >= 0.8.0 - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed - [folke/snacks.nvim](https://github.com/folke/snacks.nvim) for enhanced terminal support ## Local Installation Configuration If you've used Claude Code's `migrate-installer` command to move to a local installation, you'll need to configure the plugin to use the local path. ### What is a Local Installation? Claude Code offers a `claude migrate-installer` command that: - Moves Claude Code from a global npm installation to `~/.claude/local/` - Avoids permission issues with system directories - Creates shell aliases but these may not be available to Neovim ### Detecting Your Installation Type Check your installation type: ```bash # Check where claude command points which claude # Global installation shows: /usr/local/bin/claude (or similar) # Local installation shows: alias to ~/.claude/local/claude # Verify installation health claude doctor ``` ### Configuring for Local Installation If you have a local installation, configure the plugin with the direct path: ```lua { "coder/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, opts = { terminal_cmd = "~/.claude/local/claude", -- Point to local installation }, config = true, keys = { -- Your keymaps here }, } ```
Native Binary Installation (Alpha) Claude Code also offers an experimental native binary installation method currently in alpha testing. This provides a single executable with no Node.js dependencies. #### Installation Methods Install the native binary using one of these methods: ```bash # Fresh install (recommended) curl -fsSL claude.ai/install.sh | bash # From existing Claude Code installation claude install ``` #### Platform Support - **macOS**: Full support for Intel and Apple Silicon - **Linux**: x64 and arm64 architectures - **Windows**: Via WSL (Windows Subsystem for Linux) #### Benefits - **Zero Dependencies**: Single executable file with no external requirements - **Cross-Platform**: Consistent experience across operating systems - **Secure Installation**: Includes checksum verification and automatic cleanup #### Configuring for Native Binary The exact binary path depends on your shell integration. To find your installation: ```bash # Check where claude command points which claude # Verify installation type and health claude doctor ``` Configure the plugin with the detected path: ```lua { "coder/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, opts = { terminal_cmd = "/path/to/your/claude", -- Use output from 'which claude' }, config = true, keys = { -- Your keymaps here }, } ```
> **Note**: If Claude Code was installed globally via npm, you can use the default configuration without specifying `terminal_cmd`. ## Quick Demo ```vim " Launch Claude Code in a split :ClaudeCode " Claude now sees your current file and selections in real-time! " Send visual selection as context :'<,'>ClaudeCodeSend " Claude can open files, show diffs, and more ``` ## Usage 1. **Launch Claude**: Run `:ClaudeCode` to open Claude in a split terminal 2. **Send context**: - Select text in visual mode and use `as` to send it to Claude - In `nvim-tree`/`neo-tree`/`oil.nvim`/`mini.nvim`, press `as` on a file to add it to Claude's context 3. **Let Claude work**: Claude can now: - See your current file and selections in real-time - Open files in your editor - Show diffs with proposed changes - Access diagnostics and workspace info ## Key Commands - `:ClaudeCode` - Toggle the Claude Code terminal window - `:ClaudeCodeFocus` - Smart focus/toggle Claude terminal - `:ClaudeCodeSelectModel` - Select Claude model and open terminal with optional arguments - `:ClaudeCodeSend` - Send current visual selection to Claude - `:ClaudeCodeAdd [start-line] [end-line]` - Add specific file to Claude context with optional line range - `:ClaudeCodeDiffAccept` - Accept diff changes - `:ClaudeCodeDiffDeny` - Reject diff changes ## Working with Diffs When Claude proposes changes, the plugin opens a native Neovim diff view: - **Accept**: `:w` (save) or `aa` - **Reject**: `:q` or `ad` You can edit Claude's suggestions before accepting them. ## How It Works This plugin creates a WebSocket server that Claude Code CLI connects to, implementing the same protocol as the official VS Code extension. When you launch Claude, it automatically detects Neovim and gains full access to your editor. The protocol uses a WebSocket-based variant of MCP (Model Context Protocol) that: 1. Creates a WebSocket server on a random port 2. Writes a lock file to `~/.claude/ide/[port].lock` (or `$CLAUDE_CONFIG_DIR/ide/[port].lock` if `CLAUDE_CONFIG_DIR` is set) with connection info 3. Sets environment variables that tell Claude where to connect 4. Implements MCP tools that Claude can call 📖 **[Read the full reverse-engineering story →](./STORY.md)** 🔧 **[Complete protocol documentation →](./PROTOCOL.md)** ## Architecture Built with pure Lua and zero external dependencies: - **WebSocket Server** - RFC 6455 compliant implementation using `vim.loop` - **MCP Protocol** - Full JSON-RPC 2.0 message handling - **Lock File System** - Enables Claude CLI discovery - **Selection Tracking** - Real-time context updates - **Native Diff Support** - Seamless file comparison For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). ## Advanced Configuration ```lua { "coder/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, opts = { -- Server Configuration port_range = { min = 10000, max = 65535 }, auto_start = true, log_level = "info", -- "trace", "debug", "info", "warn", "error" terminal_cmd = nil, -- Custom terminal command (default: "claude") -- For local installations: "~/.claude/local/claude" -- For native binary: use output from 'which claude' -- Selection Tracking track_selection = true, visual_demotion_delay_ms = 50, -- Terminal Configuration terminal = { split_side = "right", -- "left" or "right" split_width_percentage = 0.30, provider = "auto", -- "auto", "snacks", "native", or custom provider table auto_close = true, snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below }, -- Diff Integration diff_opts = { auto_close_on_accept = true, vertical_split = true, open_in_current_tab = true, keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens }, }, keys = { -- Your keymaps here }, } ``` ## Floating Window Configuration The `snacks_win_opts` configuration allows you to create floating Claude Code terminals with custom positioning, sizing, and key bindings. Here are several practical examples: ### Basic Floating Window with Ctrl+, Toggle ```lua local toggle_key = "" return { { "coder/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, keys = { { toggle_key, "ClaudeCodeFocus", desc = "Claude Code", mode = { "n", "x" } }, }, opts = { terminal = { ---@module "snacks" ---@type snacks.win.Config|{} snacks_win_opts = { position = "float", width = 0.9, height = 0.9, keys = { claude_hide = { toggle_key, function(self) self:hide() end, mode = "t", desc = "Hide", }, }, }, }, }, }, } ```
Alternative with Meta+, (Alt+,) Toggle ```lua local toggle_key = "" -- Alt/Meta + comma return { { "coder/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, keys = { { toggle_key, "ClaudeCodeFocus", desc = "Claude Code", mode = { "n", "x" } }, }, opts = { terminal = { snacks_win_opts = { position = "float", width = 0.8, height = 0.8, border = "rounded", keys = { claude_hide = { toggle_key, function(self) self:hide() end, mode = "t", desc = "Hide" }, }, }, }, }, }, } ```
Centered Floating Window with Custom Styling ```lua require("claudecode").setup({ terminal = { snacks_win_opts = { position = "float", width = 0.6, height = 0.6, border = "double", backdrop = 80, keys = { claude_hide = { "", function(self) self:hide() end, mode = "t", desc = "Hide" }, claude_close = { "q", "close", mode = "n", desc = "Close" }, }, }, }, }) ```
Multiple Key Binding Options ```lua { "coder/claudecode.nvim", dependencies = { "folke/snacks.nvim" }, keys = { { "", "ClaudeCodeFocus", desc = "Claude Code (Ctrl+,)", mode = { "n", "x" } }, { "", "ClaudeCodeFocus", desc = "Claude Code (Alt+,)", mode = { "n", "x" } }, { "tc", "ClaudeCodeFocus", desc = "Toggle Claude", mode = { "n", "x" } }, }, opts = { terminal = { snacks_win_opts = { position = "float", width = 0.85, height = 0.85, border = "rounded", keys = { -- Multiple ways to hide from terminal mode claude_hide_ctrl = { "", function(self) self:hide() end, mode = "t", desc = "Hide (Ctrl+,)" }, claude_hide_alt = { "", function(self) self:hide() end, mode = "t", desc = "Hide (Alt+,)" }, claude_hide_esc = { "", function(self) self:hide() end, mode = "t", desc = "Hide (Ctrl+\\)" }, }, }, }, }, } ```
Window Position Variations ```lua -- Bottom floating (like a drawer) snacks_win_opts = { position = "bottom", height = 0.4, width = 1.0, border = "single", } -- Side floating panel snacks_win_opts = { position = "right", width = 0.4, height = 1.0, border = "rounded", } -- Small centered popup snacks_win_opts = { position = "float", width = 120, -- Fixed width in columns height = 30, -- Fixed height in rows border = "double", backdrop = 90, } ```
For complete configuration options, see: - [Snacks.nvim Terminal Documentation](https://github.com/folke/snacks.nvim/blob/main/docs/terminal.md) - [Snacks.nvim Window Documentation](https://github.com/folke/snacks.nvim/blob/main/docs/win.md) ## Custom Terminal Providers You can create custom terminal providers by passing a table with the required functions instead of a string provider name: ```lua require("claudecode").setup({ terminal = { provider = { -- Required functions setup = function(config) -- Initialize your terminal provider end, open = function(cmd_string, env_table, effective_config, focus) -- Open terminal with command and environment -- focus parameter controls whether to focus terminal (defaults to true) end, close = function() -- Close the terminal end, simple_toggle = function(cmd_string, env_table, effective_config) -- Simple show/hide toggle end, focus_toggle = function(cmd_string, env_table, effective_config) -- Smart toggle: focus terminal if not focused, hide if focused end, get_active_bufnr = function() -- Return terminal buffer number or nil return 123 -- example end, is_available = function() -- Return true if provider can be used return true end, -- Optional functions (auto-generated if not provided) toggle = function(cmd_string, env_table, effective_config) -- Defaults to calling simple_toggle for backward compatibility end, _get_terminal_for_test = function() -- For testing only, defaults to return nil return nil end, }, }, }) ``` ### Custom Provider Example Here's a complete example using a hypothetical `my_terminal` plugin: ```lua local my_terminal_provider = { setup = function(config) -- Store config for later use self.config = config end, open = function(cmd_string, env_table, effective_config, focus) if focus == nil then focus = true end local my_terminal = require("my_terminal") my_terminal.open({ cmd = cmd_string, env = env_table, width = effective_config.split_width_percentage, side = effective_config.split_side, focus = focus, }) end, close = function() require("my_terminal").close() end, simple_toggle = function(cmd_string, env_table, effective_config) require("my_terminal").toggle() end, focus_toggle = function(cmd_string, env_table, effective_config) local my_terminal = require("my_terminal") if my_terminal.is_focused() then my_terminal.hide() else my_terminal.focus() end end, get_active_bufnr = function() return require("my_terminal").get_bufnr() end, is_available = function() local ok, _ = pcall(require, "my_terminal") return ok end, } require("claudecode").setup({ terminal = { provider = my_terminal_provider, }, }) ``` The custom provider will automatically fall back to the native provider if validation fails or `is_available()` returns false. ## Community Extensions The following are third-party community extensions that complement claudecode.nvim. **These extensions are not affiliated with Coder and are maintained independently by community members.** We do not ensure that these extensions work correctly or provide support for them. ### 🔍 [claude-fzf.nvim](https://github.com/pittcat/claude-fzf.nvim) Integrates fzf-lua's file selection with claudecode.nvim's context management: - Batch file selection with fzf-lua multi-select - Smart search integration with grep → Claude - Tree-sitter based context extraction - Support for files, buffers, git files ### 📚 [claude-fzf-history.nvim](https://github.com/pittcat/claude-fzf-history.nvim) Provides convenient Claude interaction history management and access for enhanced workflow continuity. > **Disclaimer**: These community extensions are developed and maintained by independent contributors. The authors and their extensions are not affiliated with Coder. Use at your own discretion and refer to their respective repositories for installation instructions, documentation, and support. ## Troubleshooting - **Claude not connecting?** Check `:ClaudeCodeStatus` and verify lock file exists in `~/.claude/ide/` (or `$CLAUDE_CONFIG_DIR/ide/` if `CLAUDE_CONFIG_DIR` is set) - **Need debug logs?** Set `log_level = "debug"` in opts - **Terminal issues?** Try `provider = "native"` if using snacks.nvim - **Local installation not working?** If you used `claude migrate-installer`, set `terminal_cmd = "~/.claude/local/claude"` in your config. Check `which claude` vs `ls ~/.claude/local/claude` to verify your installation type. - **Native binary installation not working?** If you used the alpha native binary installer, run `claude doctor` to verify installation health and use `which claude` to find the binary path. Set `terminal_cmd = "/path/to/claude"` with the detected path in your config. ## Contributing See [DEVELOPMENT.md](./DEVELOPMENT.md) for build instructions and development guidelines. Tests can be run with `make test`. ## License [MIT](LICENSE) ## Acknowledgements - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) by Anthropic - Inspired by analyzing the official VS Code extension - Built with assistance from AI (how meta!) ================================================ FILE: ARCHITECTURE.md ================================================ # Architecture This document provides technical details about the claudecode.nvim implementation for developers and contributors. ## Overview The plugin implements a WebSocket server in pure Lua that speaks the same protocol as Anthropic's official IDE extensions. It's built entirely with Neovim built-ins (`vim.loop`, `vim.json`) with zero external dependencies. ## Core Components ### 1. WebSocket Server (`server/`) A complete RFC 6455 WebSocket implementation in pure Lua: ```lua -- server/tcp.lua - TCP server using vim.loop local tcp = vim.loop.new_tcp() tcp:bind("127.0.0.1", port) -- Always localhost! tcp:listen(128, on_connection) -- server/handshake.lua - HTTP upgrade handling -- Validates Sec-WebSocket-Key, generates Accept header local accept_key = base64(sha1(key .. WEBSOCKET_GUID)) -- server/frame.lua - WebSocket frame parser -- Handles fragmentation, masking, control frames local opcode = bit.band(byte1, 0x0F) local masked = bit.band(byte2, 0x80) ~= 0 local payload_len = bit.band(byte2, 0x7F) -- server/client.lua - Connection management -- Tracks state, handles ping/pong, manages cleanup ``` Key implementation details: - Uses `vim.schedule()` for thread-safe Neovim API calls - Implements SHA-1 in pure Lua for WebSocket handshake - Handles all WebSocket opcodes (text, binary, close, ping, pong) - Automatic ping/pong keepalive every 30 seconds ### 2. Lock File System (`lockfile.lua`) Manages discovery files for Claude CLI: ```lua -- Atomic file writing to prevent partial reads local temp_path = lock_path .. ".tmp" write_file(temp_path, json_data) vim.loop.fs_rename(temp_path, lock_path) -- Cleanup on exit vim.api.nvim_create_autocmd("VimLeavePre", { callback = function() vim.loop.fs_unlink(lock_path) end }) ``` ### 3. MCP Tool System (`tools/`) Dynamic tool registration with JSON schema validation: ```lua -- Tool registration M.register("openFile", { type = "object", properties = { filePath = { type = "string", description = "Path to open" } }, required = { "filePath" } }, function(params) -- Implementation vim.cmd("edit " .. params.filePath) return { content = {{ type = "text", text = "Opened" }} } end) -- Automatic MCP tool list generation function M.get_tool_list() local tools = {} for name, tool in pairs(registry) do if tool.schema then -- Only expose tools with schemas table.insert(tools, { name = name, description = tool.schema.description, inputSchema = tool.schema }) end end return tools end ``` ### 4. Diff System (`diff.lua`) Native Neovim diff implementation: ```lua -- Create temp file with proposed changes local temp_file = vim.fn.tempname() write_file(temp_file, new_content) -- Open diff in current tab to reduce clutter vim.cmd("edit " .. original_file) vim.cmd("diffthis") vim.cmd("vsplit " .. temp_file) vim.cmd("diffthis") -- Custom keymaps for diff mode vim.keymap.set("n", "da", accept_all_changes) vim.keymap.set("n", "dq", exit_diff_mode) ``` ### 5. Selection Tracking (`selection.lua`) Debounced selection monitoring: ```lua -- Track selection changes with debouncing local timer = nil vim.api.nvim_create_autocmd("CursorMoved", { callback = function() if timer then timer:stop() end timer = vim.defer_fn(send_selection_update, 50) end }) -- Visual mode demotion delay -- Preserves selection context when switching to terminal ``` ### 6. Terminal Integration (`terminal.lua`) Flexible terminal management with provider pattern: ```lua -- Snacks.nvim provider (preferred) if has_snacks then Snacks.terminal.open(cmd, { win = { position = "right", width = 0.3 } }) else -- Native fallback vim.cmd("vsplit | terminal " .. cmd) end ``` ## Key Implementation Patterns ### Thread Safety All Neovim API calls from async contexts use `vim.schedule()`: ```lua client:on("message", function(data) vim.schedule(function() -- Safe to use vim.* APIs here end) end) ``` ### Error Handling Consistent error propagation pattern: ```lua local ok, result = pcall(risky_operation) if not ok then logger.error("Operation failed: " .. tostring(result)) return false, result end return true, result ``` ### Resource Cleanup Automatic cleanup on shutdown: ```lua vim.api.nvim_create_autocmd("VimLeavePre", { callback = function() M.stop() -- Stop server, remove lock file end }) ``` ## Module Structure ``` lua/claudecode/ ├── init.lua # Plugin entry point ├── config.lua # Configuration management ├── server/ # WebSocket implementation │ ├── tcp.lua # TCP server (vim.loop) │ ├── handshake.lua # HTTP upgrade handling │ ├── frame.lua # RFC 6455 frame parser │ ├── client.lua # Connection management │ └── utils.lua # Pure Lua SHA-1, base64 ├── tools/init.lua # MCP tool registry ├── diff.lua # Native diff support ├── selection.lua # Selection tracking ├── terminal.lua # Terminal management └── lockfile.lua # Discovery files ``` ## Testing ### Automated Testing Three-layer testing strategy using busted: ```lua -- Unit tests: isolated function testing describe("frame parser", function() it("handles masked frames", function() local frame = parse_frame(masked_data) assert.equals("hello", frame.payload) end) end) -- Component tests: subsystem testing describe("websocket server", function() it("accepts connections", function() local server = Server:new() server:start(12345) -- Test connection logic end) end) -- Integration tests: end-to-end with mock Claude describe("full flow", function() it("handles tool calls", function() local mock_claude = create_mock_client() -- Test complete message flow end) end) ``` ### Integration Testing with Fixtures Manual testing with real Neovim configurations in the `fixtures/` directory: ```bash # Test with different file explorers source fixtures/nvim-aliases.sh vv nvim-tree # Test with nvim-tree integration vv oil # Test with oil.nvim integration vv mini-files # Test with mini.files integration vv netrw # Test with built-in netrw # Each fixture provides: # - Complete Neovim configuration # - Plugin dependencies # - Development keybindings # - Integration-specific testing scenarios ``` **Fixture Architecture**: - `fixtures/bin/` - Helper scripts (`vv`, `vve`, `list-configs`) - `fixtures/[integration]/` - Complete Neovim configs for testing - `fixtures/nvim-aliases.sh` - Shell aliases for easy testing ## Performance & Security - **Debounced Updates**: 50ms delay on selection changes - **Localhost Only**: Server binds to 127.0.0.1 - **Resource Cleanup**: Automatic on vim exit - **Memory Efficient**: Minimal footprint, no caching - **Async I/O**: Non-blocking vim.loop operations ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [0.2.0] - 2025-06-18 ### Features - **Diagnostics Integration**: Added comprehensive diagnostics tool that provides Claude with access to LSP diagnostics information ([#34](https://github.com/coder/claudecode.nvim/pull/34)) - **File Explorer Integration**: Added support for oil.nvim, nvim-tree, and neotree with @-mention file selection capabilities ([#27](https://github.com/coder/claudecode.nvim/pull/27), [#22](https://github.com/coder/claudecode.nvim/pull/22)) - **Enhanced Terminal Management**: - Added `ClaudeCodeFocus` command for smart toggle behavior ([#40](https://github.com/coder/claudecode.nvim/pull/40)) - Implemented auto terminal provider detection ([#36](https://github.com/coder/claudecode.nvim/pull/36)) - Added configurable auto-close and enhanced terminal architecture ([#31](https://github.com/coder/claudecode.nvim/pull/31)) - **Customizable Diff Keymaps**: Made diff keymaps adjustable via LazyVim spec ([#47](https://github.com/coder/claudecode.nvim/pull/47)) ### Bug Fixes - **Terminal Focus**: Fixed terminal focus error when buffer is hidden ([#43](https://github.com/coder/claudecode.nvim/pull/43)) - **Diff Acceptance**: Improved unified diff acceptance behavior using signal-based approach instead of direct file writes ([#41](https://github.com/coder/claudecode.nvim/pull/41)) - **Syntax Highlighting**: Fixed missing syntax highlighting in proposed diff view ([#32](https://github.com/coder/claudecode.nvim/pull/32)) - **Visual Selection**: Fixed visual selection range handling for `:'\<,'\>ClaudeCodeSend` ([#26](https://github.com/coder/claudecode.nvim/pull/26)) - **Native Terminal**: Implemented `bufhidden=hide` for native terminal toggle ([#39](https://github.com/coder/claudecode.nvim/pull/39)) ### Development Improvements - **Testing Infrastructure**: Moved test runner from shell script to Makefile for better development experience ([#37](https://github.com/coder/claudecode.nvim/pull/37)) - **CI/CD**: Added Claude Code GitHub Workflow ([#2](https://github.com/coder/claudecode.nvim/pull/2)) ## [0.1.0] - 2025-06-02 ### Initial Release First public release of claudecode.nvim - the first Neovim IDE integration for Claude Code. #### Features - Pure Lua WebSocket server (RFC 6455 compliant) with zero dependencies - Full MCP (Model Context Protocol) implementation compatible with official extensions - Interactive terminal integration for Claude Code CLI - Real-time selection tracking and context sharing - Native Neovim diff support for code changes - Visual selection sending with `:ClaudeCodeSend` command - Automatic server lifecycle management #### Commands - `:ClaudeCode` - Toggle Claude terminal - `:ClaudeCodeSend` - Send visual selection to Claude - `:ClaudeCodeOpen` - Open/focus Claude terminal - `:ClaudeCodeClose` - Close Claude terminal #### Requirements - Neovim >= 0.8.0 - Claude Code CLI ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides context for Claude Code when working with this codebase. ## Project Overview claudecode.nvim - A Neovim plugin that implements the same WebSocket-based MCP protocol as Anthropic's official IDE extensions. Built with pure Lua and zero dependencies. ## Common Development Commands ### Testing - `make test` - Run all tests using busted with coverage - `busted tests/unit/specific_spec.lua` - Run specific test file - `busted --coverage -v` - Run tests with coverage ### Code Quality - `make check` - Check Lua syntax and run luacheck - `make format` - Format code with stylua (or nix fmt if available) - `luacheck lua/ tests/ --no-unused-args --no-max-line-length` - Direct linting ### Build Commands - `make` - **RECOMMENDED**: Run formatting, linting, and testing (complete validation) - `make all` - Run check and format (default target) - `make test` - Run all tests using busted with coverage - `make check` - Check Lua syntax and run luacheck - `make format` - Format code with stylua (or nix fmt if available) - `make clean` - Remove generated test files - `make help` - Show available commands **Best Practice**: Always use `make` at the end of editing sessions for complete validation. ### Development with Nix - `nix develop` - Enter development shell with all dependencies - `nix fmt` - Format all files using nix formatter ### Integration Testing with Fixtures The `fixtures/` directory contains test Neovim configurations for verifying plugin integrations: - `vv ` - Start Neovim with a specific fixture configuration - `vve ` - Start Neovim with a fixture config in edit mode - `list-configs` - Show available fixture configurations - Source `fixtures/nvim-aliases.sh` to enable these commands **Available Fixtures**: - `netrw` - Tests with Neovim's built-in file explorer - `nvim-tree` - Tests with nvim-tree.lua file explorer - `oil` - Tests with oil.nvim file explorer - `mini-files` - Tests with mini.files file explorer **Usage**: `source fixtures/nvim-aliases.sh && vv oil` starts Neovim with oil.nvim configuration ## Architecture Overview ### Core Components 1. **WebSocket Server** (`lua/claudecode/server/`) - Pure Neovim implementation using vim.loop, RFC 6455 compliant 2. **MCP Tool System** (`lua/claudecode/tools/`) - Implements tools that Claude can execute (openFile, getCurrentSelection, etc.) 3. **Lock File System** (`lua/claudecode/lockfile.lua`) - Creates discovery files for Claude CLI at `~/.claude/ide/` 4. **Selection Tracking** (`lua/claudecode/selection.lua`) - Monitors text selections and sends updates to Claude 5. **Diff Integration** (`lua/claudecode/diff.lua`) - Native Neovim diff support for Claude's file comparisons 6. **Terminal Integration** (`lua/claudecode/terminal.lua`) - Manages Claude CLI terminal sessions ### WebSocket Server Implementation - **TCP Server**: `server/tcp.lua` handles port binding and connections - **Handshake**: `server/handshake.lua` processes HTTP upgrade requests with authentication - **Frame Processing**: `server/frame.lua` implements RFC 6455 WebSocket frames - **Client Management**: `server/client.lua` manages individual connections - **Utils**: `server/utils.lua` provides base64, SHA-1, XOR operations in pure Lua #### Authentication System The WebSocket server implements secure authentication using: - **UUID v4 Tokens**: Generated per session with enhanced entropy - **Header-based Auth**: Uses `x-claude-code-ide-authorization` header - **Lock File Discovery**: Tokens stored in `~/.claude/ide/[port].lock` for Claude CLI - **MCP Compliance**: Follows official Claude Code IDE authentication protocol ### MCP Tools Architecture (✅ FULLY COMPLIANT) **Complete VS Code Extension Compatibility**: All tools now implement identical behavior and output formats as the official VS Code extension. **MCP-Exposed Tools** (with JSON schemas): - `openFile` - Opens files with optional line/text selection (startLine/endLine), preview mode, text pattern matching, and makeFrontmost flag - `getCurrentSelection` - Gets current text selection from active editor - `getLatestSelection` - Gets most recent text selection (even from inactive editors) - `getOpenEditors` - Lists currently open files with VS Code-compatible `tabs` structure - `openDiff` - Opens native Neovim diff views - `checkDocumentDirty` - Checks if document has unsaved changes - `saveDocument` - Saves document with detailed success/failure reporting - `getWorkspaceFolders` - Gets workspace folder information - `closeAllDiffTabs` - Closes all diff-related tabs and windows - `getDiagnostics` - Gets language diagnostics (errors, warnings) from the editor **Internal Tools** (not exposed via MCP): - `close_tab` - Internal-only tool for tab management (hardcoded in Claude Code) **Format Compliance**: All tools return MCP-compliant format: `{content: [{type: "text", text: "JSON-stringified-data"}]}` ### Key File Locations - `lua/claudecode/init.lua` - Main entry point and setup - `lua/claudecode/config.lua` - Configuration management - `plugin/claudecode.lua` - Plugin loader with version checks - `tests/` - Comprehensive test suite with unit, component, and integration tests ## MCP Protocol Compliance ### Protocol Implementation Status - ✅ **WebSocket Server**: RFC 6455 compliant with MCP message format - ✅ **Tool Registration**: JSON Schema-based tool definitions - ✅ **Authentication**: UUID v4 token-based secure handshake - ✅ **Message Format**: JSON-RPC 2.0 with MCP content structure - ✅ **Error Handling**: Comprehensive JSON-RPC error responses ### VS Code Extension Compatibility claudecode.nvim implements **100% feature parity** with Anthropic's official VS Code extension: - **Identical Tool Set**: All 10 VS Code tools implemented - **Compatible Formats**: Output structures match VS Code extension exactly - **Behavioral Consistency**: Same parameter handling and response patterns - **Error Compatibility**: Matching error codes and messages ### Protocol Validation Run `make test` to verify MCP compliance: - **Tool Format Validation**: All tools return proper MCP structure - **Schema Compliance**: JSON schemas validated against VS Code specs - **Integration Testing**: End-to-end MCP message flow verification ## Testing Architecture Tests are organized in three layers: - **Unit tests** (`tests/unit/`) - Test individual functions in isolation - **Component tests** (`tests/component/`) - Test subsystems with controlled environment - **Integration tests** (`tests/integration/`) - End-to-end functionality with mock Claude client Test files follow the pattern `*_spec.lua` or `*_test.lua` and use the busted framework. ### Test Infrastructure **JSON Handling**: Custom JSON encoder/decoder with support for: - Nested objects and arrays - Special Lua keywords as object keys (`["end"]`) - MCP message format validation - VS Code extension output compatibility **Test Pattern**: Run specific test files during development: ```bash # Run specific tool tests with proper LUA_PATH export LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;./?/init.lua;$LUA_PATH" busted tests/unit/tools/specific_tool_spec.lua --verbose # Or use make for full validation make test # Recommended for complete validation ``` **Coverage Metrics**: - **320+ tests** covering all MCP tools and core functionality - **Unit Tests**: Individual tool behavior and error cases - **Integration Tests**: End-to-end MCP protocol flow - **Format Tests**: MCP compliance and VS Code compatibility ### Test Organization Principles - **Isolation**: Each test should be independent and not rely on external state - **Mocking**: Use comprehensive mocking for vim APIs and external dependencies - **Coverage**: Aim for both positive and negative test cases, edge cases included - **Performance**: Tests should run quickly to encourage frequent execution - **Clarity**: Test names should clearly describe what behavior is being verified ## Authentication Testing The plugin implements authentication using UUID v4 tokens that are generated for each server session and stored in lock files. This ensures secure connections between Claude CLI and the Neovim WebSocket server. ### Testing Authentication Features **Lock File Authentication Tests** (`tests/lockfile_test.lua`): - Auth token generation and uniqueness validation - Lock file creation with authentication tokens - Reading auth tokens from existing lock files - Error handling for missing or invalid tokens **WebSocket Handshake Authentication Tests** (`tests/unit/server/handshake_spec.lua`): - Valid authentication token acceptance - Invalid/missing token rejection - Edge cases (empty tokens, malformed headers, length limits) - Case-insensitive header handling **Server Integration Tests** (`tests/unit/server_spec.lua`): - Server startup with authentication tokens - Auth token state management during server lifecycle - Token validation throughout server operations **End-to-End Authentication Tests** (`tests/integration/mcp_tools_spec.lua`): - Complete authentication flow from server start to tool execution - Authentication state persistence across operations - Concurrent operations with authentication enabled ### Manual Authentication Testing **Test Script Authentication Support**: ```bash # Test scripts automatically detect and use authentication tokens cd scripts/ ./claude_interactive.sh # Automatically reads auth token from lock file ``` **Authentication Flow Testing**: 1. Start the plugin: `:ClaudeCodeStart` 2. Check lock file contains `authToken`: `cat ~/.claude/ide/*.lock | jq .authToken` 3. Test WebSocket connection with auth: Use test scripts in `scripts/` directory 4. Verify authentication in logs: Set `log_level = "debug"` in config **Testing Authentication Failures**: ```bash # Test invalid auth token (should fail) websocat ws://localhost:PORT --header "x-claude-code-ide-authorization: invalid-token" # Test missing auth header (should fail) websocat ws://localhost:PORT # Test valid auth token (should succeed) websocat ws://localhost:PORT --header "x-claude-code-ide-authorization: $(cat ~/.claude/ide/*.lock | jq -r .authToken)" ``` ### Authentication Logging Enable detailed authentication logging by setting: ```lua require("claudecode").setup({ log_level = "debug", -- Shows auth token generation, validation, and failures diff_opts = { keep_terminal_focus = true, -- If true, moves focus back to terminal after diff opens }, }) ``` ### Configuration Options #### Diff Options The `diff_opts` configuration allows you to customize diff behavior: - `keep_terminal_focus` (boolean, default: `false`) - When enabled, keeps focus in the Claude Code terminal when a diff opens instead of moving focus to the diff buffer. This allows you to continue using terminal keybindings like `` for accepting/rejecting diffs without accidentally triggering other mappings. **Example use case**: If you frequently use `` or arrow keys in the Claude Code terminal to accept/reject diffs, enable this option to prevent focus from moving to the diff buffer where `` might trigger unintended actions. ```lua require("claudecode").setup({ diff_opts = { keep_terminal_focus = true, -- If true, moves focus back to terminal after diff opens auto_close_on_accept = true, show_diff_stats = true, vertical_split = true, open_in_current_tab = true, }, }) ``` Log levels for authentication events: - **DEBUG**: Server startup authentication state, client connections, handshake processing, auth token details - **WARN**: Authentication failures during handshake - **ERROR**: Auth token generation failures, handshake response errors ### Logging Best Practices - **Connection Events**: Use DEBUG level for routine connection establishment/teardown - **Authentication Flow**: Use DEBUG for successful auth, WARN for failures - **User-Facing Events**: Use INFO sparingly for events users need to know about - **System Errors**: Use ERROR for failures that require user attention ## Development Notes ### Technical Requirements - Plugin requires Neovim >= 0.8.0 - Uses only Neovim built-ins for WebSocket implementation (vim.loop, vim.json, vim.schedule) - Zero external dependencies for core functionality ### Security Considerations - WebSocket server only accepts local connections (127.0.0.1) for security - Authentication tokens are UUID v4 with enhanced entropy - Lock files created at `~/.claude/ide/[port].lock` for Claude CLI discovery - All authentication events are logged for security auditing ### Performance Optimizations - Selection tracking is debounced to reduce overhead - WebSocket frame processing optimized for JSON-RPC payload sizes - Connection pooling and cleanup to prevent resource leaks ### Integration Support - Terminal integration supports both snacks.nvim and native Neovim terminal - Compatible with popular file explorers (nvim-tree, oil.nvim, neo-tree, mini.files) - Visual selection tracking across different selection modes ## Release Process ### Version Updates When updating the version number for a new release, you must update **ALL** of these files: 1. **`lua/claudecode/init.lua`** - Main version table: ```lua M.version = { major = 0, minor = 2, -- Update this patch = 0, -- Update this prerelease = nil, -- Remove for stable releases } ``` 2. **`scripts/claude_interactive.sh`** - Multiple client version references: - Line ~52: `"version": "0.2.0"` (handshake) - Line ~223: `"version": "0.2.0"` (initialize) - Line ~309: `"version": "0.2.0"` (reconnect) 3. **`scripts/lib_claude.sh`** - ClaudeCodeNvim version: - Line ~120: `"version": "0.2.0"` (init message) 4. **`CHANGELOG.md`** - Add new release section with: - Release date - Features with PR references - Bug fixes with PR references - Development improvements ### Release Commands ```bash # Get merged PRs since last version gh pr list --state merged --base main --json number,title,mergedAt,url --jq 'sort_by(.mergedAt) | reverse' # Get commit history git log --oneline v0.1.0..HEAD # Always run before committing make # Verify no old version references remain rg "0\.1\.0" . # Should only show CHANGELOG.md historical entries ``` ## Development Workflow ### Pre-commit Requirements **ALWAYS run `make` before committing any changes.** This runs code quality checks and formatting that must pass for CI to succeed. Never skip this step - many PRs fail CI because contributors don't run the build commands before committing. ### Recommended Development Flow 1. **Start Development**: Use existing tests and documentation to understand the system 2. **Make Changes**: Follow existing patterns and conventions in the codebase 3. **Validate Work**: Run `make` to ensure formatting, linting, and tests pass 4. **Document Changes**: Update relevant documentation (this file, PROTOCOL.md, etc.) 5. **Commit**: Only commit after successful `make` execution ### Integration Development Guidelines **Adding New Integrations** (file explorers, terminals, etc.): 1. **Implement Integration**: Add support in relevant modules (e.g., `lua/claudecode/tools/`) 2. **Create Fixture Configuration**: **REQUIRED** - Add a complete Neovim config in `fixtures/[integration-name]/` 3. **Test Integration**: Use fixture to verify functionality with `vv [integration-name]` 4. **Update Documentation**: Add integration to fixtures list and relevant tool documentation 5. **Run Full Test Suite**: Ensure `make` passes with new integration **Fixture Requirements**: - Complete Neovim configuration with plugin dependencies - Include `dev-claudecode.lua` with development keybindings - Test all relevant claudecode.nvim features with the integration - Document any integration-specific behaviors or limitations ### MCP Tool Development Guidelines **Adding New Tools**: 1. **Study Existing Patterns**: Review `lua/claudecode/tools/` for consistent structure 2. **Implement Handler**: Return MCP format: `{content: [{type: "text", text: JSON}]}` 3. **Add JSON Schema**: Define parameters and expose via MCP (if needed) 4. **Create Tests**: Both unit tests and integration tests required 5. **Update Documentation**: Add to this file's MCP tools list **Tool Testing Pattern**: ```lua -- All tools should return MCP-compliant format local result = tool_handler(params) expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") local parsed = json_decode(result.content[1].text) -- Validate parsed structure matches VS Code extension ``` **Error Handling Standard**: ```lua -- Use consistent JSON-RPC error format error({ code = -32602, -- Invalid params message = "Description of the issue", data = "Additional context" }) ``` ### Code Quality Standards - **Test Coverage**: Maintain comprehensive test coverage (currently **320+ tests**, 100% success rate) - **Zero Warnings**: All code must pass luacheck with 0 warnings/errors - **MCP Compliance**: All tools must return proper MCP format with JSON-stringified content - **VS Code Compatibility**: New tools must match VS Code extension behavior exactly - **Consistent Formatting**: Use `nix fmt` or `stylua` for consistent code style - **Documentation**: Update CLAUDE.md for architectural changes, PROTOCOL.md for protocol changes ### Development Quality Gates 1. **`make check`** - Syntax and linting (0 warnings required) 2. **`make test`** - All tests passing (320/320 success rate required) 3. **`make format`** - Consistent code formatting 4. **MCP Validation** - Tools return proper format structure 5. **Integration Test** - End-to-end protocol flow verification ## Development Troubleshooting ### Common Issues **Test Failures with LUA_PATH**: ```bash # Tests can't find modules - use proper LUA_PATH export LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;./?/init.lua;$LUA_PATH" busted tests/unit/specific_test.lua ``` **JSON Format Issues**: - Ensure all tools return: `{content: [{type: "text", text: "JSON-string"}]}` - Use `vim.json.encode()` for proper JSON stringification - Test JSON parsing with custom test decoder in `tests/busted_setup.lua` **MCP Tool Registration**: - Tools with `schema = nil` are internal-only - Tools with schema are exposed via MCP - Check `lua/claudecode/tools/init.lua` for registration patterns **Authentication Testing**: ```bash # Verify auth token generation cat ~/.claude/ide/*.lock | jq .authToken # Test WebSocket connection websocat ws://localhost:PORT --header "x-claude-code-ide-authorization: $(cat ~/.claude/ide/*.lock | jq -r .authToken)" ``` ================================================ FILE: dev-config.lua ================================================ -- Development configuration for claudecode.nvim -- This is Thomas's personal config for developing claudecode.nvim -- Symlink this to your personal Neovim config: -- ln -s ~/projects/claudecode.nvim/dev-config.lua ~/.config/nvim/lua/plugins/dev-claudecode.lua return { "coder/claudecode.nvim", dev = true, -- Use local development version keys = { -- AI/Claude Code prefix { "a", nil, desc = "AI/Claude Code" }, -- Core Claude commands { "ac", "ClaudeCode", desc = "Toggle Claude" }, { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, { "am", "ClaudeCodeSelectModel", desc = "Select Claude model" }, -- Context sending { "as", "ClaudeCodeAdd %", mode = "n", desc = "Add current buffer" }, { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, { "as", "ClaudeCodeTreeAdd", desc = "Add file from tree", ft = { "NvimTree", "neo-tree", "oil", "minifiles" }, }, -- Development helpers { "ao", "ClaudeCodeOpen", desc = "Open Claude" }, { "aq", "ClaudeCodeClose", desc = "Close Claude" }, { "ai", "ClaudeCodeStatus", desc = "Claude Status" }, { "aS", "ClaudeCodeStart", desc = "Start Claude Server" }, { "aQ", "ClaudeCodeStop", desc = "Stop Claude Server" }, -- Diff management (buffer-local, only active in diff buffers) { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, { "ad", "ClaudeCodeDiffDeny", desc = "Deny diff" }, }, -- Development configuration - all options shown with defaults commented out opts = { -- Server Configuration -- port_range = { min = 10000, max = 65535 }, -- WebSocket server port range -- auto_start = true, -- Auto-start server on Neovim startup -- log_level = "info", -- "trace", "debug", "info", "warn", "error" -- terminal_cmd = nil, -- Custom terminal command (default: "claude") -- Selection Tracking -- track_selection = true, -- Enable real-time selection tracking -- visual_demotion_delay_ms = 50, -- Delay before demoting visual selection (ms) -- Connection Management -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) -- connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) -- queue_timeout = 5000, -- Max time to keep @ mentions in queue (ms) -- Diff Integration -- diff_opts = { -- auto_close_on_accept = true, -- Close diff view after accepting changes -- show_diff_stats = true, -- Show diff statistics -- vertical_split = true, -- Use vertical split for diffs -- open_in_current_tab = true, -- Open diffs in current tab vs new tab -- keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens -- }, -- Terminal Configuration -- terminal = { -- split_side = "right", -- "left" or "right" -- split_width_percentage = 0.30, -- Width as percentage (0.0 to 1.0) -- provider = "auto", -- "auto", "snacks", or "native" -- show_native_term_exit_tip = true, -- Show exit tip for native terminal -- auto_close = true, -- Auto-close terminal after command completion -- snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` -- }, -- Development overrides (uncomment as needed) -- log_level = "debug", -- terminal = { -- provider = "native", -- auto_close = false, -- Keep terminals open to see output -- }, }, } ================================================ FILE: DEVELOPMENT.md ================================================ # Development Guide Quick guide for contributors to the claudecode.nvim project. ## Project Structure ```none claudecode.nvim/ ├── .github/workflows/ # CI workflow definitions ├── fixtures/ # Test Neovim configurations for integration testing │ ├── bin/ # Helper scripts (vv, vve, list-configs) │ ├── mini-files/ # Neovim config testing with mini.files │ ├── netrw/ # Neovim config testing with built-in file explorer │ ├── nvim-tree/ # Neovim config testing with nvim-tree.lua │ ├── oil/ # Neovim config testing with oil.nvim │ └── nvim-aliases.sh # Shell aliases for fixture testing ├── lua/claudecode/ # Plugin implementation │ ├── server/ # WebSocket server implementation │ ├── tools/ # MCP tool implementations and schema management │ ├── config.lua # Configuration management │ ├── diff.lua # Diff provider system (native Neovim support) │ ├── init.lua # Plugin entry point │ ├── lockfile.lua # Lock file management │ ├── selection.lua # Selection tracking │ └── terminal.lua # Terminal management ├── plugin/ # Plugin loader ├── tests/ # Test suite │ ├── unit/ # Unit tests │ ├── component/ # Component tests │ ├── integration/ # Integration tests │ └── mocks/ # Test mocks ├── README.md # User documentation ├── ARCHITECTURE.md # Architecture documentation └── DEVELOPMENT.md # Development guide ``` ## Core Components Implementation Status | Component | Status | Priority | Notes | | ---------------------- | ------- | -------- | -------------------------------------------------------------------- | | Basic plugin structure | ✅ Done | - | Initial setup complete | | Configuration system | ✅ Done | - | Support for user configuration | | WebSocket server | ✅ Done | - | Pure Lua RFC 6455 compliant | | Lock file management | ✅ Done | - | Basic implementation complete | | Selection tracking | ✅ Done | - | Enhanced with multi-mode support | | MCP tools framework | ✅ Done | - | Dynamic tool registration and schema system | | Core MCP tools | ✅ Done | - | openFile, openDiff, getCurrentSelection, getOpenEditors | | Diff integration | ✅ Done | - | Native Neovim diff with configurable options | | Terminal integration | ✅ Done | - | Snacks.nvim and native terminal support | | Tests | ✅ Done | - | Comprehensive test suite with unit, component, and integration tests | | CI pipeline | ✅ Done | - | GitHub Actions configured | | Documentation | ✅ Done | - | Complete documentation | ## Development Priorities 1. **Advanced MCP Tools** - Add Neovim-specific tools (LSP integration, diagnostics, Telescope integration) - Implement diffview.nvim integration for the diff provider system - Add Git integration tools (branch info, status, etc.) 2. **Performance Optimization** - Monitor WebSocket server performance under load - Optimize selection tracking for large files - Fine-tune debouncing and event handling 3. **User Experience** - Add more user commands and keybindings - Improve error messages and user feedback - Create example configurations for popular setups 4. **Integration Testing** - Test with real Claude Code CLI - Validate compatibility across Neovim versions - Create end-to-end test scenarios ## Testing Run tests using: ```bash # Run all tests make test # Run specific test file nvim --headless -u tests/minimal_init.lua -c "lua require('tests.unit.config_spec')" # Run linting make check # Format code make format ``` ## Implementation Guidelines 1. **Error Handling** - All public functions should have error handling - Return `success, result_or_error` pattern - Log meaningful error messages 2. **Performance** - Minimize impact on editor performance - Debounce event handlers - Use asynchronous operations where possible 3. **Compatibility** - Support Neovim >= 0.8.0 - Zero external dependencies (pure Lua implementation) - Follow Neovim plugin best practices 4. **Testing** - Write tests before implementation (TDD) - Aim for high code coverage - Mock external dependencies ## Contributing 1. Fork the repository 2. Create a feature branch 3. Implement your changes with tests 4. Run the test suite to ensure all tests pass 5. **For integrations**: Create a fixture configuration for testing 6. Submit a pull request ### Integration Testing with Fixtures When adding support for new integrations (file explorers, terminals, etc.), you **must** provide a fixture configuration for testing: **Requirements**: - Complete Neovim configuration in `fixtures/[integration-name]/` - Include plugin dependencies and proper setup - Add `dev-claudecode.lua` with development keybindings - Test all relevant claudecode.nvim features with the integration **Usage**: ```bash # Source fixture aliases source fixtures/nvim-aliases.sh # Test with specific integration vv nvim-tree # Start Neovim with nvim-tree configuration vv oil # Start Neovim with oil.nvim configuration vv mini-files # Start Neovim with mini.files configuration vv netrw # Start Neovim with built-in netrw configuration # List available configurations list-configs ``` **Example fixture structure** (`fixtures/my-integration/`): ``` my-integration/ ├── init.lua # Main Neovim config ├── lua/ │ ├── config/ │ │ └── lazy.lua # Plugin manager setup │ └── plugins/ │ ├── dev-claudecode.lua # claudecode.nvim development config │ ├── init.lua # Base plugins │ └── my-integration.lua # Integration-specific plugin config └── lazy-lock.json # Plugin lockfile (if using lazy.nvim) ``` ## Implementation Details ### WebSocket Server The WebSocket server is implemented in pure Lua with zero external dependencies: - **Pure Neovim Implementation**: Uses `vim.loop` (libuv) for TCP operations - **RFC 6455 Compliant**: Full WebSocket protocol implementation - **JSON-RPC 2.0**: MCP message handling with proper framing - **Security**: Pure Lua SHA-1 implementation for WebSocket handshake - **Performance**: Optimized with lookup tables and efficient algorithms ### MCP Tool System The plugin implements a sophisticated tool system following MCP 2025-03-26: **Current Tools:** - `openFile` - Opens files with optional line/text selection - `openDiff` - Native Neovim diff views with configurable options - `getCurrentSelection` - Gets current text selection - `getOpenEditors` - Lists currently open files **Tool Architecture:** - Dynamic registration: `M.register(name, schema, handler)` - Automatic MCP exposure based on schema presence - JSON schema validation for parameters - Centralized tool definitions in `tools/init.lua` **Future Tools:** - Neovim-specific diagnostics - LSP integration (hover, references, definitions) - Telescope integration for file finding - Git integration (status, branch info, blame) ## Next Steps 1. Implement diffview.nvim integration for the diff provider system 2. Add advanced MCP tools (LSP integration, Telescope, Git) 3. Add integration tests with real Claude Code CLI 4. Optimize performance for large codebases 5. Create example configurations for popular Neovim setups (LazyVim, NvChad, etc.) ================================================ FILE: flake.lock ================================================ { "nodes": { "flake-utils": { "inputs": { "systems": "systems" }, "locked": { "lastModified": 1731533236, "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" } }, "nixpkgs": { "locked": { "lastModified": 1753694789, "narHash": "sha256-cKgvtz6fKuK1Xr5LQW/zOUiAC0oSQoA9nOISB0pJZqM=", "owner": "NixOS", "repo": "nixpkgs", "rev": "dc9637876d0dcc8c9e5e22986b857632effeb727", "type": "github" }, "original": { "owner": "NixOS", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_2": { "locked": { "lastModified": 1747958103, "narHash": "sha256-qmmFCrfBwSHoWw7cVK4Aj+fns+c54EBP8cGqp/yK410=", "owner": "nixos", "repo": "nixpkgs", "rev": "fe51d34885f7b5e3e7b59572796e1bcb427eccb1", "type": "github" }, "original": { "owner": "nixos", "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" } }, "systems": { "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "type": "github" }, "original": { "owner": "nix-systems", "repo": "default", "type": "github" } }, "treefmt-nix": { "inputs": { "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1753772294, "narHash": "sha256-8rkd13WfClfZUBIYpX5dvG3O9V9w3K9FPQ9rY14VtBE=", "owner": "numtide", "repo": "treefmt-nix", "rev": "6b9214fffbcf3f1e608efa15044431651635ca83", "type": "github" }, "original": { "owner": "numtide", "repo": "treefmt-nix", "type": "github" } } }, "root": "root", "version": 7 } ================================================ FILE: flake.nix ================================================ { description = "Claude Code Neovim plugin development environment"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; treefmt-nix.url = "github:numtide/treefmt-nix"; }; outputs = { self, nixpkgs, flake-utils, treefmt-nix, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; }; treefmt = treefmt-nix.lib.evalModule pkgs { projectRootFile = "flake.nix"; programs = { stylua.enable = true; nixpkgs-fmt.enable = true; prettier.enable = true; shfmt.enable = true; actionlint.enable = true; zizmor.enable = true; shellcheck.enable = true; }; settings.formatter.shellcheck.options = [ "--exclude=SC1091,SC2016" ]; settings.formatter.prettier.excludes = [ # Exclude lazy.nvim lock files as they are auto-generated # and will be reformatted by lazy on each package update "fixtures/*/lazy-lock.json" ]; }; # CI-specific packages (minimal set for testing and linting) ciPackages = with pkgs; [ lua5_1 luajitPackages.luacheck luajitPackages.busted luajitPackages.luacov neovim treefmt.config.build.wrapper findutils ]; # Development packages (additional tools for development) devPackages = with pkgs; [ ast-grep luarocks gnumake websocat jq fzf # claude-code ]; in { # Format the source tree formatter = treefmt.config.build.wrapper; # Check formatting checks.formatting = treefmt.config.build.check self; devShells = { # Minimal CI environment ci = pkgs.mkShell { buildInputs = ciPackages; }; # Full development environment default = pkgs.mkShell { buildInputs = ciPackages ++ devPackages; }; }; } ); } ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2025 Coder Technologies Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: check format test clean # Default target all: format check test # Detect if we are already inside a Nix shell ifeq (,$(IN_NIX_SHELL)) NIX_PREFIX := nix develop .#ci -c else NIX_PREFIX := endif # Check for syntax errors check: @echo "Checking Lua files for syntax errors..." $(NIX_PREFIX) find lua -name "*.lua" -type f -exec lua -e "assert(loadfile('{}'))" \; @echo "Running luacheck..." $(NIX_PREFIX) luacheck lua/ tests/ --no-unused-args --no-max-line-length # Format all files format: nix fmt # Run tests test: @echo "Running all tests..." @export LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;./?/init.lua;$$LUA_PATH"; \ TEST_FILES=$$(find tests -type f -name "*_test.lua" -o -name "*_spec.lua" | sort); \ echo "Found test files:"; \ echo "$$TEST_FILES"; \ if [ -n "$$TEST_FILES" ]; then \ $(NIX_PREFIX) busted --coverage -v $$TEST_FILES; \ else \ echo "No test files found"; \ fi # Clean generated files clean: @echo "Cleaning generated files..." @rm -f luacov.report.out luacov.stats.out @rm -f tests/lcov.info # Print available commands help: @echo "Available commands:" @echo " make check - Check for syntax errors" @echo " make format - Format all files (uses nix fmt or stylua)" @echo " make test - Run tests" @echo " make clean - Clean generated files" @echo " make help - Print this help message" ================================================ FILE: PROTOCOL.md ================================================ # How Claude Code IDE Extensions Actually Work This document explains the protocol and architecture behind Claude Code's IDE integrations, based on reverse-engineering the VS Code extension. Use this guide to build your own integrations or understand how the official ones work. ## TL;DR Claude Code extensions create WebSocket servers in your IDE that Claude connects to. They use a WebSocket variant of MCP (Model Context Protocol) that only Claude supports. The IDE writes a lock file with connection info, sets some environment variables, and Claude automatically connects when launched. ## How Discovery Works When you launch Claude Code from your IDE, here's what happens: ### 1. IDE Creates a WebSocket Server The extension starts a WebSocket server on a random port (10000-65535) that listens for connections from Claude. ### 2. Lock File Creation The IDE writes a discovery file to `~/.claude/ide/[port].lock`: ```json { "pid": 12345, // IDE process ID "workspaceFolders": ["/path/to/project"], // Open folders "ideName": "VS Code", // or "Neovim", "IntelliJ", etc. "transport": "ws", // WebSocket transport "authToken": "550e8400-e29b-41d4-a716-446655440000" // Random UUID for authentication } ``` ### 3. Environment Variables When launching Claude, the IDE sets: - `CLAUDE_CODE_SSE_PORT`: The WebSocket server port - `ENABLE_IDE_INTEGRATION`: Set to "true" ### 4. Claude Connects Claude reads the lock files, finds the matching port from the environment, and connects to the WebSocket server. ## Authentication When Claude connects to the IDE's WebSocket server, it must authenticate using the token from the lock file. The authentication happens via a custom WebSocket header: ``` x-claude-code-ide-authorization: 550e8400-e29b-41d4-a716-446655440000 ``` The IDE validates this header against the `authToken` value from the lock file. If the token doesn't match, the connection is rejected. ## The Protocol Communication uses WebSocket with JSON-RPC 2.0 messages: ```json { "jsonrpc": "2.0", "method": "method_name", "params": { /* parameters */ }, "id": "unique-id" // for requests that expect responses } ``` The protocol is based on MCP (Model Context Protocol) specification 2025-03-26, but uses WebSocket transport instead of stdio/HTTP. ## Key Message Types ### From IDE to Claude These are notifications the IDE sends to keep Claude informed: #### 1. Selection Updates Sent whenever the user's selection changes: ```json { "jsonrpc": "2.0", "method": "selection_changed", "params": { "text": "selected text content", "filePath": "/absolute/path/to/file.js", "fileUrl": "file:///absolute/path/to/file.js", "selection": { "start": { "line": 10, "character": 5 }, "end": { "line": 15, "character": 20 }, "isEmpty": false } } } ``` #### 2. At-Mentions When the user explicitly sends a selection as context: ```json { "jsonrpc": "2.0", "method": "at_mentioned", "params": { "filePath": "/path/to/file", "lineStart": 10, "lineEnd": 20 } } ``` ### From Claude to IDE According to the MCP spec, Claude should be able to call tools, but **current implementations are mostly one-way** (IDE → Claude). #### Tool Calls (Future) ```json { "jsonrpc": "2.0", "id": "request-123", "method": "tools/call", "params": { "name": "openFile", "arguments": { "filePath": "/path/to/file.js" } } } ``` #### Tool Responses ```json { "jsonrpc": "2.0", "id": "request-123", "result": { "content": [{ "type": "text", "text": "File opened successfully" }] } } ``` ## Available MCP Tools The VS Code extension registers 12 tools that Claude can call. Here's the complete specification: ### 1. openFile **Description**: Open a file in the editor and optionally select a range of text **Input**: ```json { "filePath": "/path/to/file.js", "preview": false, "startText": "function hello", "endText": "}", "selectToEndOfLine": false, "makeFrontmost": true } ``` - `filePath` (string, required): Path to the file to open - `preview` (boolean, default: false): Whether to open in preview mode - `startText` (string, optional): Text pattern to find selection start - `endText` (string, optional): Text pattern to find selection end - `selectToEndOfLine` (boolean, default: false): Extend selection to end of line - `makeFrontmost` (boolean, default: true): Make the file the active editor tab **Output**: When `makeFrontmost=true`, returns simple message: ```json { "content": [ { "type": "text", "text": "Opened file: /path/to/file.js" } ] } ``` When `makeFrontmost=false`, returns detailed JSON: ```json { "content": [ { "type": "text", "text": "{\"success\": true, \"filePath\": \"/absolute/path/to/file.js\", \"languageId\": \"javascript\", \"lineCount\": 42}" } ] } ``` ### 2. openDiff **Description**: Open a git diff for the file (blocking operation) **Input**: ```json { "old_file_path": "/path/to/original.js", "new_file_path": "/path/to/modified.js", "new_file_contents": "// Modified content...", "tab_name": "Proposed changes" } ``` - `old_file_path` (string): Path to original file - `new_file_path` (string): Path to new file - `new_file_contents` (string): Contents of the new file - `tab_name` (string): Tab name for the diff view **Output**: Returns MCP-formatted response: ```json { "content": [ { "type": "text", "text": "FILE_SAVED" } ] } ``` or ```json { "content": [ { "type": "text", "text": "DIFF_REJECTED" } ] } ``` Based on whether the user saves or rejects the diff. ### 3. getCurrentSelection **Description**: Get the current text selection in the active editor **Input**: None **Output**: Returns JSON-stringified selection data: ```json { "content": [ { "type": "text", "text": "{\"success\": true, \"text\": \"selected content\", \"filePath\": \"/path/to/file\", \"selection\": {\"start\": {\"line\": 0, \"character\": 0}, \"end\": {\"line\": 0, \"character\": 10}}}" } ] } ``` Or when no active editor: ```json { "content": [ { "type": "text", "text": "{\"success\": false, \"message\": \"No active editor found\"}" } ] } ``` ### 4. getLatestSelection **Description**: Get the most recent text selection (even if not in active editor) **Input**: None **Output**: JSON-stringified selection data or `{success: false, message: "No selection available"}` ### 5. getOpenEditors **Description**: Get information about currently open editors **Input**: None **Output**: Returns JSON-stringified array of open tabs: ```json { "content": [ { "type": "text", "text": "{\"tabs\": [{\"uri\": \"file:///path/to/file\", \"isActive\": true, \"label\": \"filename.ext\", \"languageId\": \"javascript\", \"isDirty\": false}]}" } ] } ``` ### 6. getWorkspaceFolders **Description**: Get all workspace folders currently open in the IDE **Input**: None **Output**: Returns JSON-stringified workspace information: ```json { "content": [ { "type": "text", "text": "{\"success\": true, \"folders\": [{\"name\": \"project-name\", \"uri\": \"file:///path/to/workspace\", \"path\": \"/path/to/workspace\"}], \"rootPath\": \"/path/to/workspace\"}" } ] } ``` ### 7. getDiagnostics **Description**: Get language diagnostics from VS Code **Input**: ```json { "uri": "file:///path/to/file.js" } ``` - `uri` (string, optional): File URI to get diagnostics for. If not provided, gets diagnostics for all files. **Output**: Returns JSON-stringified array of diagnostics per file: ```json { "content": [ { "type": "text", "text": "[{\"uri\": \"file:///path/to/file\", \"diagnostics\": [{\"message\": \"Error message\", \"severity\": \"Error\", \"range\": {\"start\": {\"line\": 0, \"character\": 0}}, \"source\": \"typescript\"}]}]" } ] } ``` ### 8. checkDocumentDirty **Description**: Check if a document has unsaved changes (is dirty) **Input**: ```json { "filePath": "/path/to/file.js" } ``` - `filePath` (string, required): Path to the file to check **Output**: Returns document dirty status: ```json { "content": [ { "type": "text", "text": "{\"success\": true, \"filePath\": \"/path/to/file.js\", \"isDirty\": true, \"isUntitled\": false}" } ] } ``` Or when document not open: ```json { "content": [ { "type": "text", "text": "{\"success\": false, \"message\": \"Document not open: /path/to/file.js\"}" } ] } ``` ### 9. saveDocument **Description**: Save a document with unsaved changes **Input**: ```json { "filePath": "/path/to/file.js" } ``` - `filePath` (string, required): Path to the file to save **Output**: Returns save operation result: ```json { "content": [ { "type": "text", "text": "{\"success\": true, \"filePath\": \"/path/to/file.js\", \"saved\": true, \"message\": \"Document saved successfully\"}" } ] } ``` Or when document not open: ```json { "content": [ { "type": "text", "text": "{\"success\": false, \"message\": \"Document not open: /path/to/file.js\"}" } ] } ``` ### 10. close_tab **Description**: Close a tab by name **Input**: ```json { "tab_name": "filename.js" } ``` - `tab_name` (string, required): Name of the tab to close **Output**: Returns `{content: [{type: "text", text: "TAB_CLOSED"}]}` ### 11. closeAllDiffTabs **Description**: Close all diff tabs in the editor **Input**: None **Output**: Returns `{content: [{type: "text", text: "CLOSED_${count}_DIFF_TABS"}]}` ### 12. executeCode **Description**: Execute Python code in the Jupyter kernel for the current notebook file **Input**: ```json { "code": "print('Hello, World!')" } ``` - `code` (string, required): The code to be executed on the kernel **Output**: Returns execution results with mixed content types: ```json { "content": [ { "type": "text", "text": "Hello, World!" }, { "type": "image", "data": "base64_encoded_image_data", "mimeType": "image/png" } ] } ``` **Notes**: - All code executed will persist across calls unless the kernel is restarted - Avoid declaring variables or modifying kernel state unless explicitly requested - Only available when working with Jupyter notebooks - Can return multiple content types including text output and images ### Implementation Notes - Most tools follow camelCase naming except `close_tab` (uses snake_case) - The `openDiff` tool is **blocking** and waits for user interaction - Tools return MCP-formatted responses with content arrays - All schemas use Zod validation in the VS Code extension - Selection-related tools work with the current editor state ## Building Your Own Integration Here's the minimum viable implementation: ### 1. Create a WebSocket Server ```lua -- Listen on localhost only (important!) local server = create_websocket_server("127.0.0.1", random_port) ``` ### 2. Write the Lock File ```lua -- ~/.claude/ide/[port].lock local auth_token = generate_uuid() -- Generate random UUID local lock_data = { pid = vim.fn.getpid(), workspaceFolders = { vim.fn.getcwd() }, ideName = "YourEditor", transport = "ws", authToken = auth_token } write_json(lock_path, lock_data) ``` ### 3. Set Environment Variables ```bash export CLAUDE_CODE_SSE_PORT=12345 export ENABLE_IDE_INTEGRATION=true claude # Claude will now connect! ``` ### 4. Handle Messages ```lua -- Validate authentication on WebSocket handshake function validate_auth(headers) local auth_header = headers["x-claude-code-ide-authorization"] return auth_header == auth_token end -- Send selection updates send_message({ jsonrpc = "2.0", method = "selection_changed", params = { ... } }) -- Implement tools (if needed) register_tool("openFile", function(params) -- Open file logic return { content = {{ type = "text", text = "Done" }} } end) ``` ## Security Considerations **Always bind to localhost (`127.0.0.1`) only!** This ensures the WebSocket server is not exposed to the network. ## What's Next? With this protocol knowledge, you can: - Build integrations for any editor - Create agents that connect to existing IDE extensions - Extend the protocol with custom tools - Build bridges between different AI assistants and IDEs The WebSocket MCP variant is currently Claude-specific, but the concepts could be adapted for other AI coding assistants. ## Resources - [MCP Specification](https://spec.modelcontextprotocol.io) - [Claude Code Neovim Implementation](https://github.com/coder/claudecode.nvim) - [Official VS Code Extension](https://github.com/anthropic-labs/vscode-mcp) (minified source) ================================================ FILE: STORY.md ================================================ # The Story: How I Reverse-Engineered Claude's IDE Protocol ## The Reddit Post That Started Everything While browsing Reddit at DevOpsCon in London, I stumbled upon a post that caught my eye: someone mentioned finding .vsix files in Anthropic's npm package for their Claude Code VS Code extension. Link to the Reddit post: My first thought? "No way, they wouldn't ship the source like that." But curiosity got the better of me. I checked npm, and there they were — the .vsix files, just sitting in the vendor folder. ## Down the Rabbit Hole A .vsix file is just a fancy ZIP archive. So naturally, I decompressed it. What I found was a single line of minified JavaScript — 10,000 lines worth when prettified. Completely unreadable. But here's where it gets interesting. I'd been playing with AST-grep, a tool that combines the power of grep with tree-sitter for semantic code understanding. Instead of just searching text, it understands code structure. ## Using AI to Understand AI I had a crazy idea: What if I used Claude to help me understand Claude's own extension? I fed the prettified code to Claude and asked it to write AST-grep queries to rename obfuscated variables based on their usage patterns. For example: ```javascript // Before const L = new McpToolRegistry(); // After const toolRegistry = new McpToolRegistry(); ``` Suddenly, patterns emerged. Functions revealed their purpose. The fog lifted. ## The Discovery What I discovered was fascinating: 1. **It's all MCP** — The extensions use Model Context Protocol, but with a twist 2. **WebSocket Transport** — Unlike standard MCP (which uses stdio/HTTP), these use WebSockets 3. **Claude-Specific** — Claude Code is the only MCP client that supports WebSocket transport 4. **Simple Protocol** — The IDE creates a server, Claude connects to it ## Building for Neovim Armed with this knowledge, I faced a new challenge: I wanted this in Neovim, but I didn't know Lua. So I did what any reasonable person would do in 2025 — I used AI to help me build it. Using Roo Code with Gemini 2.5 Pro, I scaffolded a Neovim plugin that implements the same protocol. (Note: Claude 4 models were not publicly available at the time of writing the extension.) The irony isn't lost on me: I used AI to reverse-engineer an AI tool, then used AI to build a plugin for AI. ## The Technical Challenge Building a WebSocket server in pure Lua with only Neovim built-ins was... interesting: - Implemented SHA-1 from scratch (needed for WebSocket handshake) - Built a complete RFC 6455 WebSocket frame parser - Created base64 encoding/decoding functions - All using just `vim.loop` and basic Lua No external dependencies. Just pure, unadulterated Neovim. ## What This Means This discovery opens doors: 1. **Any editor can integrate** — The protocol is simple and well-defined 2. **Agents can connect** — You could build automation that connects to any IDE with these extensions 3. **The protocol is extensible** — New tools can be added easily 4. **It's educational** — Understanding how these work demystifies AI coding assistants ## Lessons Learned 1. **Curiosity pays off** — That Reddit post led to this entire journey 2. **Tools matter** — AST-grep was instrumental in understanding the code 3. **AI can build AI tools** — We're in a recursive loop of AI development 4. **Open source wins** — By understanding the protocol, we can build for any platform ## What's Next? The protocol is documented. The implementation is open source. Now it's your turn. Build integrations for Emacs, Sublime, or your favorite editor. Create agents that leverage IDE access. Extend the protocol with new capabilities. The genie is out of the bottle, and it speaks WebSocket. --- _If you found this story interesting, check out the [protocol documentation](./PROTOCOL.md) for implementation details, or dive into the [code](https://github.com/coder/claudecode.nvim) to see how it all works._ ================================================ FILE: .envrc ================================================ #!/usr/bin/env bash if ! has nix_direnv_version || ! nix_direnv_version 3.0.7; then source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.7/direnvrc" "sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM=" fi nix_direnv_manual_reload use flake . # Add fixtures/bin to PATH for nvim config aliases PATH_add fixtures/bin ================================================ FILE: .luacheckrc ================================================ -- Luacheck configuration for Claude Code Neovim plugin -- Set global variable names globals = { "vim", "expect", "assert_contains", "assert_not_contains", "spy", -- For luassert.spy and spy.any } -- Ignore warnings for unused self parameters self = false -- Allow trailing whitespace ignore = { "212/self", -- Unused argument 'self' "631", -- Line contains trailing whitespace } -- Set max line length max_line_length = 120 -- Allow using external modules allow_defined_top = true allow_defined = true -- Enable more checking std = "luajit+busted" -- Ignore tests/ directory for performance exclude_files = { "tests/mocks", } ================================================ FILE: .stylua.toml ================================================ # StyLua configuration column_width = 120 line_endings = "Unix" indent_type = "Spaces" indent_width = 2 quote_style = "AutoPreferDouble" call_parentheses = "Always" [sort_requires] enabled = true ================================================ FILE: fixtures/nvim-aliases.sh ================================================ #!/bin/bash # Test Neovim configurations with fixture configs # This script provides aliases that call the executable scripts in bin/ # Get script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BIN_DIR="$SCRIPT_DIR/bin" # Create aliases that call the bin scripts # shellcheck disable=SC2139 alias vv="$BIN_DIR/vv" # shellcheck disable=SC2139 alias vve="$BIN_DIR/vve" # shellcheck disable=SC2139 alias list-configs="$BIN_DIR/list-configs" echo "Neovim configuration aliases loaded!" echo "Use 'vv ' or 'vve ' to test configurations" echo "Use 'list-configs' to see available options" ================================================ FILE: fixtures/mini-files/init.lua ================================================ require("config.lazy") ================================================ FILE: fixtures/mini-files/lazy-lock.json ================================================ { "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, "mini.files": { "branch": "main", "commit": "5b9431cf5c69b8e69e5a67d2d12338a3ac2e1541" }, "tokyonight.nvim": { "branch": "main", "commit": "057ef5d260c1931f1dffd0f052c685dcd14100a3" } } ================================================ FILE: fixtures/mini-files/lua/config/lazy.lua ================================================ -- Bootstrap lazy.nvim local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" if not (vim.uv or vim.loop).fs_stat(lazypath) then local lazyrepo = "https://github.com/folke/lazy.nvim.git" local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) if vim.v.shell_error ~= 0 then vim.api.nvim_echo({ { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, { out, "WarningMsg" }, { "\nPress any key to exit..." }, }, true, {}) vim.fn.getchar() os.exit(1) end end vim.opt.rtp:prepend(lazypath) -- Make sure to setup `mapleader` and `maplocalleader` before -- loading lazy.nvim so that mappings are correct. -- This is also a good place to setup other settings (vim.opt) vim.g.mapleader = " " vim.g.maplocalleader = "\\" -- Setup lazy.nvim require("lazy").setup({ spec = { -- import your plugins { import = "plugins" }, }, -- Configure any other settings here. See the documentation for more details. -- colorscheme that will be used when installing plugins. install = { colorscheme = { "habamax" } }, -- automatically check for plugin updates checker = { enabled = true }, }) -- Add keybind for Lazy plugin manager vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) -- Terminal keybindings vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) ================================================ FILE: fixtures/mini-files/lua/plugins/init.lua ================================================ -- Basic plugin configuration return { -- Example: add a colorscheme { "folke/tokyonight.nvim", lazy = false, priority = 1000, config = function() vim.cmd([[colorscheme tokyonight]]) end, }, } ================================================ FILE: fixtures/mini-files/lua/plugins/mini-files.lua ================================================ return { "echasnovski/mini.files", version = false, config = function() require("mini.files").setup({ -- Customization of shown content content = { -- Predicate for which file system entries to show filter = nil, -- What prefix to show to the left of file system entry prefix = nil, -- In which order to show file system entries sort = nil, }, -- Module mappings created only inside explorer. -- Use `''` (empty string) to not create one. mappings = { close = "q", go_in = "l", go_in_plus = "L", go_out = "h", go_out_plus = "H", reset = "", reveal_cwd = "@", show_help = "g?", synchronize = "=", trim_left = "<", trim_right = ">", }, -- General options options = { -- Whether to delete permanently or move into module-specific trash permanent_delete = true, -- Whether to use for editing directories use_as_default_explorer = true, }, -- Customization of explorer windows windows = { -- Maximum number of windows to show side by side max_number = math.huge, -- Whether to show preview of file/directory under cursor preview = false, -- Width of focused window width_focus = 50, -- Width of non-focused window width_nofocus = 15, -- Width of preview window width_preview = 25, }, }) -- Global keybindings for mini.files vim.keymap.set("n", "e", function() require("mini.files").open() end, { desc = "Open mini.files (current dir)" }) vim.keymap.set("n", "E", function() require("mini.files").open(vim.api.nvim_buf_get_name(0)) end, { desc = "Open mini.files (current file)" }) vim.keymap.set("n", "-", function() require("mini.files").open() end, { desc = "Open parent directory" }) -- Mini.files specific keybindings and autocommands vim.api.nvim_create_autocmd("User", { pattern = "MiniFilesBufferCreate", callback = function(args) local buf_id = args.data.buf_id -- Add buffer-local keybindings vim.keymap.set("n", "", function() -- Split window and open file local cur_target = require("mini.files").get_fs_entry() if cur_target and cur_target.fs_type == "file" then require("mini.files").close() vim.cmd("split " .. cur_target.path) end end, { buffer = buf_id, desc = "Split and open file" }) vim.keymap.set("n", "", function() -- Vertical split and open file local cur_target = require("mini.files").get_fs_entry() if cur_target and cur_target.fs_type == "file" then require("mini.files").close() vim.cmd("vsplit " .. cur_target.path) end end, { buffer = buf_id, desc = "Vertical split and open file" }) vim.keymap.set("n", "", function() -- Open in new tab local cur_target = require("mini.files").get_fs_entry() if cur_target and cur_target.fs_type == "file" then require("mini.files").close() vim.cmd("tabnew " .. cur_target.path) end end, { buffer = buf_id, desc = "Open in new tab" }) -- Create new file/directory vim.keymap.set("n", "a", function() local cur_target = require("mini.files").get_fs_entry() local path = cur_target and cur_target.path or require("mini.files").get_explorer_state().cwd local new_name = vim.fn.input("Create: " .. path .. "/") if new_name and new_name ~= "" then if new_name:sub(-1) == "/" then -- Create directory vim.fn.mkdir(path .. "/" .. new_name, "p") else -- Create file local new_file = io.open(path .. "/" .. new_name, "w") if new_file then new_file:close() end end require("mini.files").refresh() end end, { buffer = buf_id, desc = "Create new file/directory" }) -- Rename file/directory vim.keymap.set("n", "r", function() local cur_target = require("mini.files").get_fs_entry() if cur_target then local old_name = vim.fn.fnamemodify(cur_target.path, ":t") local new_name = vim.fn.input("Rename to: ", old_name) if new_name and new_name ~= "" and new_name ~= old_name then local new_path = vim.fn.fnamemodify(cur_target.path, ":h") .. "/" .. new_name os.rename(cur_target.path, new_path) require("mini.files").refresh() end end end, { buffer = buf_id, desc = "Rename file/directory" }) -- Delete file/directory vim.keymap.set("n", "d", function() local cur_target = require("mini.files").get_fs_entry() if cur_target then local confirm = vim.fn.confirm("Delete " .. cur_target.path .. "?", "&Yes\n&No", 2) if confirm == 1 then if cur_target.fs_type == "directory" then vim.fn.delete(cur_target.path, "rf") else vim.fn.delete(cur_target.path) end require("mini.files").refresh() end end end, { buffer = buf_id, desc = "Delete file/directory" }) end, }) -- Auto-close mini.files when it's the last window vim.api.nvim_create_autocmd("User", { pattern = "MiniFilesBufferUpdate", callback = function() if vim.bo.filetype == "minifiles" then -- Check if this is the only window left local windows = vim.api.nvim_list_wins() local minifiles_windows = 0 for _, win in ipairs(windows) do local buf = vim.api.nvim_win_get_buf(win) if vim.api.nvim_buf_get_option(buf, "filetype") == "minifiles" then minifiles_windows = minifiles_windows + 1 end end if #windows == minifiles_windows then vim.cmd("quit") end end end, }) end, } ================================================ SYMLINK: fixtures/mini-files/lua/plugins/dev-claudecode.lua -> dev-config.lua ================================================ ================================================ FILE: fixtures/netrw/init.lua ================================================ require("config.lazy") ================================================ FILE: fixtures/netrw/lazy-lock.json ================================================ { "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, "tokyonight.nvim": { "branch": "main", "commit": "057ef5d260c1931f1dffd0f052c685dcd14100a3" } } ================================================ FILE: fixtures/netrw/lua/config/lazy.lua ================================================ -- Bootstrap lazy.nvim local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" if not (vim.uv or vim.loop).fs_stat(lazypath) then local lazyrepo = "https://github.com/folke/lazy.nvim.git" local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) if vim.v.shell_error ~= 0 then vim.api.nvim_echo({ { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, { out, "WarningMsg" }, { "\nPress any key to exit..." }, }, true, {}) vim.fn.getchar() os.exit(1) end end vim.opt.rtp:prepend(lazypath) -- Make sure to setup `mapleader` and `maplocalleader` before -- loading lazy.nvim so that mappings are correct. -- This is also a good place to setup other settings (vim.opt) vim.g.mapleader = " " vim.g.maplocalleader = "\\" -- Setup lazy.nvim require("lazy").setup({ spec = { -- import your plugins { import = "plugins" }, }, -- Configure any other settings here. See the documentation for more details. -- colorscheme that will be used when installing plugins. install = { colorscheme = { "habamax" } }, -- automatically check for plugin updates checker = { enabled = true }, }) -- Add keybind for Lazy plugin manager vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) -- Terminal keybindings vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) ================================================ FILE: fixtures/netrw/lua/config/netrw.lua ================================================ -- Netrw configuration for file browsing -- This replaces file managers like nvim-tree or oil.nvim -- Configure netrw settings early vim.g.loaded_netrw = nil vim.g.loaded_netrwPlugin = nil -- Netrw settings vim.g.netrw_banner = 0 -- Hide banner vim.g.netrw_liststyle = 3 -- Tree view vim.g.netrw_browse_split = 4 -- Open in previous window vim.g.netrw_altv = 1 -- Split to the right vim.g.netrw_winsize = 25 -- 25% width vim.g.netrw_keepdir = 0 -- Keep current dir in sync vim.g.netrw_localcopydircmd = "cp -r" -- Hide dotfiles by default (toggle with gh) vim.g.netrw_list_hide = [[.*\..*]] vim.g.netrw_hide = 1 -- Use system open command if vim.fn.has("mac") == 1 then vim.g.netrw_browsex_viewer = "open" elseif vim.fn.has("unix") == 1 then vim.g.netrw_browsex_viewer = "xdg-open" end ================================================ FILE: fixtures/netrw/lua/plugins/init.lua ================================================ -- Basic plugin configuration return { -- Example: add a colorscheme { "folke/tokyonight.nvim", lazy = false, priority = 1000, config = function() vim.cmd([[colorscheme tokyonight]]) end, }, } ================================================ FILE: fixtures/netrw/lua/plugins/netrw-keymaps.lua ================================================ -- Netrw keymaps setup return { { "netrw-keymaps", dir = vim.fn.stdpath("config"), name = "netrw-keymaps", config = function() -- Set up global keymaps vim.keymap.set("n", "e", function() if vim.bo.filetype == "netrw" then vim.cmd("bd") else vim.cmd("Explore") end end, { desc = "Toggle file explorer" }) vim.keymap.set("n", "E", "Vexplore", { desc = "Open file explorer (split)" }) -- Netrw-specific keymaps (active in netrw buffers only) vim.api.nvim_create_autocmd("FileType", { pattern = "netrw", callback = function() local buf = vim.api.nvim_get_current_buf() local opts = { buffer = buf } vim.keymap.set("n", "h", "-", vim.tbl_extend("force", opts, { desc = "Go up directory" })) vim.keymap.set("n", "l", "", vim.tbl_extend("force", opts, { desc = "Enter directory/open file" })) vim.keymap.set("n", ".", "gh", vim.tbl_extend("force", opts, { desc = "Toggle hidden files" })) vim.keymap.set("n", "P", "z", vim.tbl_extend("force", opts, { desc = "Close preview" })) vim.keymap.set("n", "dd", "D", vim.tbl_extend("force", opts, { desc = "Delete file/directory" })) vim.keymap.set("n", "r", "R", vim.tbl_extend("force", opts, { desc = "Rename file" })) vim.keymap.set("n", "n", "%", vim.tbl_extend("force", opts, { desc = "Create new file" })) vim.keymap.set("n", "N", "d", vim.tbl_extend("force", opts, { desc = "Create new directory" })) end, }) end, }, } ================================================ FILE: fixtures/netrw/lua/plugins/snacks.lua ================================================ if true then return {} end return { "folke/snacks.nvim", priority = 1000, lazy = false, opts = { bigfile = { enabled = true }, dashboard = { enabled = true }, explorer = { enabled = true }, notifier = { enabled = true }, quickfile = { enabled = true }, statuscolumn = { enabled = true }, words = { enabled = true }, }, } ================================================ SYMLINK: fixtures/netrw/lua/plugins/dev-claudecode.lua -> dev-config.lua ================================================ ================================================ FILE: fixtures/nvim-tree/init.lua ================================================ require("config.lazy") ================================================ FILE: fixtures/nvim-tree/lazy-lock.json ================================================ { "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, "nvim-tree.lua": { "branch": "master", "commit": "0a7fcdf3f8ba208f4260988a198c77ec11748339" }, "nvim-web-devicons": { "branch": "master", "commit": "3362099de3368aa620a8105b19ed04c2053e38c0" }, "tokyonight.nvim": { "branch": "main", "commit": "057ef5d260c1931f1dffd0f052c685dcd14100a3" } } ================================================ FILE: fixtures/nvim-tree/lua/config/lazy.lua ================================================ -- Bootstrap lazy.nvim local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" if not (vim.uv or vim.loop).fs_stat(lazypath) then local lazyrepo = "https://github.com/folke/lazy.nvim.git" local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) if vim.v.shell_error ~= 0 then vim.api.nvim_echo({ { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, { out, "WarningMsg" }, { "\nPress any key to exit..." }, }, true, {}) vim.fn.getchar() os.exit(1) end end vim.opt.rtp:prepend(lazypath) -- Make sure to setup `mapleader` and `maplocalleader` before -- loading lazy.nvim so that mappings are correct. -- This is also a good place to setup other settings (vim.opt) vim.g.mapleader = " " vim.g.maplocalleader = "\\" -- Setup lazy.nvim require("lazy").setup({ spec = { -- import your plugins { import = "plugins" }, }, -- Configure any other settings here. See the documentation for more details. -- colorscheme that will be used when installing plugins. install = { colorscheme = { "habamax" } }, -- automatically check for plugin updates checker = { enabled = true }, }) -- Add keybind for Lazy plugin manager vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) -- Terminal keybindings vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) ================================================ FILE: fixtures/nvim-tree/lua/plugins/init.lua ================================================ -- Basic plugin configuration return { -- Example: add a colorscheme { "folke/tokyonight.nvim", lazy = false, priority = 1000, config = function() vim.cmd([[colorscheme tokyonight]]) end, }, } ================================================ FILE: fixtures/nvim-tree/lua/plugins/nvim-tree.lua ================================================ return { "nvim-tree/nvim-tree.lua", dependencies = { "nvim-tree/nvim-web-devicons", }, config = function() require("nvim-tree").setup({ view = { width = 30, }, renderer = { group_empty = true, }, filters = { dotfiles = true, }, }) -- Key mappings vim.keymap.set("n", "", ":NvimTreeToggle", { silent = true }) vim.keymap.set("n", "e", ":NvimTreeFocus", { silent = true }) end, } ================================================ SYMLINK: fixtures/nvim-tree/lua/plugins/dev-claudecode.lua -> dev-config.lua ================================================ ================================================ FILE: fixtures/oil/init.lua ================================================ require("config.lazy") ================================================ FILE: fixtures/oil/lazy-lock.json ================================================ { "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, "mini.icons": { "branch": "main", "commit": "b8f6fa6f5a3fd0c56936252edcd691184e5aac0c" }, "oil.nvim": { "branch": "master", "commit": "bbad9a76b2617ce1221d49619e4e4b659b3c61fc" }, "tokyonight.nvim": { "branch": "main", "commit": "057ef5d260c1931f1dffd0f052c685dcd14100a3" } } ================================================ FILE: fixtures/oil/lua/config/lazy.lua ================================================ -- Bootstrap lazy.nvim local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" if not (vim.uv or vim.loop).fs_stat(lazypath) then local lazyrepo = "https://github.com/folke/lazy.nvim.git" local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) if vim.v.shell_error ~= 0 then vim.api.nvim_echo({ { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, { out, "WarningMsg" }, { "\nPress any key to exit..." }, }, true, {}) vim.fn.getchar() os.exit(1) end end vim.opt.rtp:prepend(lazypath) -- Make sure to setup `mapleader` and `maplocalleader` before -- loading lazy.nvim so that mappings are correct. -- This is also a good place to setup other settings (vim.opt) vim.g.mapleader = " " vim.g.maplocalleader = "\\" -- Setup lazy.nvim require("lazy").setup({ spec = { -- import your plugins { import = "plugins" }, }, -- Configure any other settings here. See the documentation for more details. -- colorscheme that will be used when installing plugins. install = { colorscheme = { "habamax" } }, -- automatically check for plugin updates checker = { enabled = true }, }) -- Add keybind for Lazy plugin manager vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) -- Terminal keybindings vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) ================================================ FILE: fixtures/oil/lua/plugins/init.lua ================================================ -- Basic plugin configuration return { -- Example: add a colorscheme { "folke/tokyonight.nvim", lazy = false, priority = 1000, config = function() vim.cmd([[colorscheme tokyonight]]) end, }, } ================================================ FILE: fixtures/oil/lua/plugins/oil-nvim.lua ================================================ return { "stevearc/oil.nvim", ---@module 'oil' ---@type oil.SetupOpts opts = { default_file_explorer = true, columns = { "icon", "permissions", "size", "mtime", }, view_options = { show_hidden = false, }, float = { padding = 2, max_width = 90, max_height = 0, border = "rounded", win_options = { winblend = 0, }, }, }, -- Optional dependencies dependencies = { { "echasnovski/mini.icons", opts = {} } }, -- dependencies = { "nvim-tree/nvim-web-devicons" }, -- use if you prefer nvim-web-devicons -- Lazy loading is not recommended because it is very tricky to make it work correctly in all situations. lazy = false, config = function(_, opts) require("oil").setup(opts) -- Global keybindings for oil vim.keymap.set("n", "o", "Oil", { desc = "Open Oil (current dir)" }) vim.keymap.set("n", "O", "Oil --float", { desc = "Open Oil (floating)" }) vim.keymap.set("n", "-", "Oil", { desc = "Open parent directory" }) -- Oil-specific keybindings (only active in Oil buffers) vim.api.nvim_create_autocmd("FileType", { pattern = "oil", callback = function() vim.keymap.set("n", "", "Oil --float", { buffer = true, desc = "Open Oil float" }) vim.keymap.set("n", "g.", function() require("oil").toggle_hidden() end, { buffer = true, desc = "Toggle hidden files" }) vim.keymap.set("n", "", function() require("oil").set_columns({ "icon", "permissions", "size", "mtime" }) end, { buffer = true, desc = "Show detailed view" }) vim.keymap.set("n", "", function() require("oil").set_columns({ "icon" }) end, { buffer = true, desc = "Show simple view" }) end, }) end, } ================================================ SYMLINK: fixtures/oil/lua/plugins/dev-claudecode.lua -> dev-config.lua ================================================ ================================================ FILE: lua/claudecode/config.lua ================================================ ---@brief [[ --- Manages configuration for the Claude Code Neovim integration. --- Provides default settings, validation, and application of user-defined configurations. ---@brief ]] ---@module 'claudecode.config' local M = {} -- Types (authoritative for configuration shape): ---@class ClaudeCode.DiffOptions ---@field auto_close_on_accept boolean ---@field show_diff_stats boolean ---@field vertical_split boolean ---@field open_in_current_tab boolean ---@field keep_terminal_focus boolean ---@class ClaudeCode.ModelOption ---@field name string ---@field value string ---@alias ClaudeCode.LogLevel "trace"|"debug"|"info"|"warn"|"error" ---@class ClaudeCode.Config ---@field port_range {min: integer, max: integer} ---@field auto_start boolean ---@field terminal_cmd string|nil ---@field env table ---@field log_level ClaudeCode.LogLevel ---@field track_selection boolean ---@field visual_demotion_delay_ms number ---@field connection_wait_delay number ---@field connection_timeout number ---@field queue_timeout number ---@field diff_opts ClaudeCode.DiffOptions ---@field models ClaudeCode.ModelOption[] ---@field disable_broadcast_debouncing? boolean ---@field enable_broadcast_debouncing_in_tests? boolean ---@field terminal TerminalConfig|nil M.defaults = { port_range = { min = 10000, max = 65535 }, auto_start = true, terminal_cmd = nil, env = {}, -- Custom environment variables for Claude terminal log_level = "info", track_selection = true, visual_demotion_delay_ms = 50, -- Milliseconds to wait before demoting a visual selection connection_wait_delay = 200, -- Milliseconds to wait after connection before sending queued @ mentions connection_timeout = 10000, -- Maximum time to wait for Claude Code to connect (milliseconds) queue_timeout = 5000, -- Maximum time to keep @ mentions in queue (milliseconds) diff_opts = { auto_close_on_accept = true, show_diff_stats = true, vertical_split = true, open_in_current_tab = true, -- Use current tab instead of creating new tab keep_terminal_focus = false, -- If true, moves focus back to terminal after diff opens }, models = { { name = "Claude Opus 4 (Latest)", value = "opus" }, { name = "Claude Sonnet 4 (Latest)", value = "sonnet" }, }, terminal = nil, -- Will be lazy-loaded to avoid circular dependency } ---Validates the provided configuration table. ---Throws an error if any validation fails. ---@param config table The configuration table to validate. ---@return boolean true if the configuration is valid. function M.validate(config) assert( type(config.port_range) == "table" and type(config.port_range.min) == "number" and type(config.port_range.max) == "number" and config.port_range.min > 0 and config.port_range.max <= 65535 and config.port_range.min <= config.port_range.max, "Invalid port range" ) assert(type(config.auto_start) == "boolean", "auto_start must be a boolean") assert(config.terminal_cmd == nil or type(config.terminal_cmd) == "string", "terminal_cmd must be nil or a string") local valid_log_levels = { "trace", "debug", "info", "warn", "error" } local is_valid_log_level = false for _, level in ipairs(valid_log_levels) do if config.log_level == level then is_valid_log_level = true break end end assert(is_valid_log_level, "log_level must be one of: " .. table.concat(valid_log_levels, ", ")) assert(type(config.track_selection) == "boolean", "track_selection must be a boolean") assert( type(config.visual_demotion_delay_ms) == "number" and config.visual_demotion_delay_ms >= 0, "visual_demotion_delay_ms must be a non-negative number" ) assert( type(config.connection_wait_delay) == "number" and config.connection_wait_delay >= 0, "connection_wait_delay must be a non-negative number" ) assert( type(config.connection_timeout) == "number" and config.connection_timeout > 0, "connection_timeout must be a positive number" ) assert(type(config.queue_timeout) == "number" and config.queue_timeout > 0, "queue_timeout must be a positive number") assert(type(config.diff_opts) == "table", "diff_opts must be a table") assert(type(config.diff_opts.auto_close_on_accept) == "boolean", "diff_opts.auto_close_on_accept must be a boolean") assert(type(config.diff_opts.show_diff_stats) == "boolean", "diff_opts.show_diff_stats must be a boolean") assert(type(config.diff_opts.vertical_split) == "boolean", "diff_opts.vertical_split must be a boolean") assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean") assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean") -- Validate env assert(type(config.env) == "table", "env must be a table") for key, value in pairs(config.env) do assert(type(key) == "string", "env keys must be strings") assert(type(value) == "string", "env values must be strings") end -- Validate models assert(type(config.models) == "table", "models must be a table") assert(#config.models > 0, "models must not be empty") for i, model in ipairs(config.models) do assert(type(model) == "table", "models[" .. i .. "] must be a table") assert(type(model.name) == "string" and model.name ~= "", "models[" .. i .. "].name must be a non-empty string") assert(type(model.value) == "string" and model.value ~= "", "models[" .. i .. "].value must be a non-empty string") end return true end ---Applies user configuration on top of default settings and validates the result. ---@param user_config table|nil The user-provided configuration table. ---@return ClaudeCode.Config config The final, validated configuration table. function M.apply(user_config) local config = vim.deepcopy(M.defaults) -- Lazy-load terminal defaults to avoid circular dependency if config.terminal == nil then local terminal_ok, terminal_module = pcall(require, "claudecode.terminal") if terminal_ok and terminal_module.defaults then config.terminal = terminal_module.defaults end end if user_config then -- Use vim.tbl_deep_extend if available, otherwise simple merge if vim.tbl_deep_extend then config = vim.tbl_deep_extend("force", config, user_config) else -- Simple fallback for testing environment for k, v in pairs(user_config) do config[k] = v end end end M.validate(config) return config end return M ================================================ FILE: lua/claudecode/diff.lua ================================================ --- Diff module for Claude Code Neovim integration. -- Provides native Neovim diff functionality with MCP-compliant blocking operations and state management. local M = {} local logger = require("claudecode.logger") -- Global state management for active diffs local active_diffs = {} local autocmd_group local config ---Get or create the autocmd group local function get_autocmd_group() if not autocmd_group then autocmd_group = vim.api.nvim_create_augroup("ClaudeCodeMCPDiff", { clear = true }) end return autocmd_group end ---Find a suitable main editor window to open diffs in. ---Excludes terminals, sidebars, and floating windows. ---@return number? win_id Window ID of the main editor window, or nil if not found local function find_main_editor_window() local windows = vim.api.nvim_list_wins() for _, win in ipairs(windows) do local buf = vim.api.nvim_win_get_buf(win) local buftype = vim.api.nvim_buf_get_option(buf, "buftype") local filetype = vim.api.nvim_buf_get_option(buf, "filetype") local win_config = vim.api.nvim_win_get_config(win) -- Check if this is a suitable window local is_suitable = true -- Skip floating windows if win_config.relative and win_config.relative ~= "" then is_suitable = false end -- Skip special buffer types if is_suitable and (buftype == "terminal" or buftype == "prompt") then is_suitable = false end -- Skip known sidebar filetypes if is_suitable and ( filetype == "neo-tree" or filetype == "neo-tree-popup" or filetype == "NvimTree" or filetype == "oil" or filetype == "minifiles" or filetype == "aerial" or filetype == "tagbar" ) then is_suitable = false end -- This looks like a main editor window if is_suitable then return win end end return nil end ---Find the Claude Code terminal window to keep focus there. ---Uses the terminal provider to get the active terminal buffer, then finds its window. ---@return number? win_id Window ID of the Claude Code terminal window, or nil if not found local function find_claudecode_terminal_window() local terminal_ok, terminal_module = pcall(require, "claudecode.terminal") if not terminal_ok then return nil end local terminal_bufnr = terminal_module.get_active_terminal_bufnr() if not terminal_bufnr then return nil end -- Find the window containing this buffer for _, win in ipairs(vim.api.nvim_list_wins()) do if vim.api.nvim_win_get_buf(win) == terminal_bufnr then local win_config = vim.api.nvim_win_get_config(win) -- Skip floating windows if not (win_config.relative and win_config.relative ~= "") then return win end end end return nil end ---Check if a buffer has unsaved changes (is dirty). ---@param file_path string The file path to check ---@return boolean true if the buffer is dirty, false otherwise ---@return string? error message if file is not open local function is_buffer_dirty(file_path) local bufnr = vim.fn.bufnr(file_path) if bufnr == -1 then return false, "File not currently open in buffer" end local is_dirty = vim.api.nvim_buf_get_option(bufnr, "modified") return is_dirty, nil end ---Setup the diff module ---@param user_config table? The configuration passed from init.lua function M.setup(user_config) -- Store the configuration for later use config = user_config or {} end ---Open a diff view between two files ---@param old_file_path string Path to the original file ---@param new_file_path string Path to the new file (used for naming) ---@param new_file_contents string Contents of the new file ---@param tab_name string Name for the diff tab/view ---@return table Result with provider, tab_name, and success status function M.open_diff(old_file_path, new_file_path, new_file_contents, tab_name) return M._open_native_diff(old_file_path, new_file_path, new_file_contents, tab_name) end ---Create a temporary file with content ---@param content string The content to write ---@param filename string Base filename for the temporary file ---@return string? path, string? error The temporary file path and error message local function create_temp_file(content, filename) local base_dir_cache = vim.fn.stdpath("cache") .. "/claudecode_diffs" local mkdir_ok_cache, mkdir_err_cache = pcall(vim.fn.mkdir, base_dir_cache, "p") local final_base_dir if mkdir_ok_cache then final_base_dir = base_dir_cache else local base_dir_temp = vim.fn.stdpath("cache") .. "/claudecode_diffs_fallback" local mkdir_ok_temp, mkdir_err_temp = pcall(vim.fn.mkdir, base_dir_temp, "p") if not mkdir_ok_temp then local err_to_report = mkdir_err_temp or mkdir_err_cache or "unknown error creating base temp dir" return nil, "Failed to create base temporary directory: " .. tostring(err_to_report) end final_base_dir = base_dir_temp end local session_id_base = vim.fn.fnamemodify(vim.fn.tempname(), ":t") .. "_" .. tostring(os.time()) .. "_" .. tostring(math.random(1000, 9999)) local session_id = session_id_base:gsub("[^A-Za-z0-9_-]", "") if session_id == "" then -- Fallback if all characters were problematic, ensuring a directory can be made. session_id = "claudecode_session" end local tmp_session_dir = final_base_dir .. "/" .. session_id local mkdir_session_ok, mkdir_session_err = pcall(vim.fn.mkdir, tmp_session_dir, "p") if not mkdir_session_ok then return nil, "Failed to create temporary session directory: " .. tostring(mkdir_session_err) end local tmp_file = tmp_session_dir .. "/" .. filename local file = io.open(tmp_file, "w") if not file then return nil, "Failed to create temporary file: " .. tmp_file end file:write(content) file:close() return tmp_file, nil end ---Clean up temporary files and directories ---@param tmp_file string Path to the temporary file to clean up local function cleanup_temp_file(tmp_file) if tmp_file and vim.fn.filereadable(tmp_file) == 1 then local tmp_dir = vim.fn.fnamemodify(tmp_file, ":h") if vim.fs and type(vim.fs.remove) == "function" then local ok_file, err_file = pcall(vim.fs.remove, tmp_file) if not ok_file then vim.notify( "ClaudeCode: Error removing temp file " .. tmp_file .. ": " .. tostring(err_file), vim.log.levels.WARN ) end local ok_dir, err_dir = pcall(vim.fs.remove, tmp_dir) if not ok_dir then vim.notify( "ClaudeCode: Error removing temp directory " .. tmp_dir .. ": " .. tostring(err_dir), vim.log.levels.INFO ) end else local reason = "vim.fs.remove is not a function" if not vim.fs then reason = "vim.fs is nil" end vim.notify( "ClaudeCode: Cannot perform standard cleanup: " .. reason .. ". Affected file: " .. tmp_file .. ". Please check your Neovim setup or report this issue.", vim.log.levels.ERROR ) -- Fallback to os.remove for the file. local os_ok, os_err = pcall(os.remove, tmp_file) if not os_ok then vim.notify( "ClaudeCode: Fallback os.remove also failed for file " .. tmp_file .. ": " .. tostring(os_err), vim.log.levels.ERROR ) end end end end ---Detect filetype from a path or existing buffer (best-effort) local function detect_filetype(path, buf) -- 1) Try Neovim's builtin matcher if available (>=0.10) if vim.filetype and type(vim.filetype.match) == "function" then local ok, ft = pcall(vim.filetype.match, { filename = path }) if ok and ft and ft ~= "" then return ft end end -- 2) Try reading from existing buffer if buf and vim.api.nvim_buf_is_valid(buf) then local ft = vim.api.nvim_buf_get_option(buf, "filetype") if ft and ft ~= "" then return ft end end -- 3) Fallback to simple extension mapping local ext = path:match("%.([%w_%-]+)$") or "" local simple_map = { lua = "lua", ts = "typescript", js = "javascript", jsx = "javascriptreact", tsx = "typescriptreact", py = "python", go = "go", rs = "rust", c = "c", h = "c", cpp = "cpp", hpp = "cpp", md = "markdown", sh = "sh", zsh = "zsh", bash = "bash", json = "json", yaml = "yaml", yml = "yaml", toml = "toml", } return simple_map[ext] end ---Open diff using native Neovim functionality ---@param old_file_path string Path to the original file ---@param new_file_path string Path to the new file (used for naming) ---@param new_file_contents string Contents of the new file ---@param tab_name string Name for the diff tab/view ---@return table res Result with provider, tab_name, and success status function M._open_native_diff(old_file_path, new_file_path, new_file_contents, tab_name) local new_filename = vim.fn.fnamemodify(new_file_path, ":t") .. ".new" local tmp_file, err = create_temp_file(new_file_contents, new_filename) if not tmp_file then return { provider = "native", tab_name = tab_name, success = false, error = err, temp_file = nil } end local target_win = find_main_editor_window() if target_win then vim.api.nvim_set_current_win(target_win) else vim.cmd("wincmd t") vim.cmd("wincmd l") local buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) local buftype = vim.api.nvim_buf_get_option(buf, "buftype") if buftype == "terminal" or buftype == "nofile" then vim.cmd("vsplit") end end vim.cmd("edit " .. vim.fn.fnameescape(old_file_path)) vim.cmd("diffthis") vim.cmd("vsplit") vim.cmd("edit " .. vim.fn.fnameescape(tmp_file)) vim.api.nvim_buf_set_name(0, new_file_path .. " (New)") -- Propagate filetype to the proposed buffer for proper syntax highlighting (#20) local proposed_buf = vim.api.nvim_get_current_buf() local old_filetype = detect_filetype(old_file_path) if old_filetype and old_filetype ~= "" then vim.api.nvim_set_option_value("filetype", old_filetype, { buf = proposed_buf }) end vim.cmd("wincmd =") local new_buf = proposed_buf vim.api.nvim_set_option_value("buftype", "nofile", { buf = new_buf }) vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = new_buf }) vim.api.nvim_set_option_value("swapfile", false, { buf = new_buf }) vim.cmd("diffthis") local cleanup_group = vim.api.nvim_create_augroup("ClaudeCodeDiffCleanup", { clear = false }) vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { group = cleanup_group, buffer = new_buf, callback = function() cleanup_temp_file(tmp_file) end, once = true, }) return { provider = "native", tab_name = tab_name, success = true, temp_file = tmp_file, } end ---Register diff state for tracking ---@param tab_name string Unique identifier for the diff ---@param diff_data table Diff state data function M._register_diff_state(tab_name, diff_data) active_diffs[tab_name] = diff_data end ---Resolve diff as saved (user accepted changes) ---@param tab_name string The diff identifier ---@param buffer_id number The buffer that was saved function M._resolve_diff_as_saved(tab_name, buffer_id) local diff_data = active_diffs[tab_name] if not diff_data or diff_data.status ~= "pending" then return end logger.debug("diff", "Resolving diff as saved for", tab_name, "from buffer", buffer_id) -- Get content from buffer local content_lines = vim.api.nvim_buf_get_lines(buffer_id, 0, -1, false) local final_content = table.concat(content_lines, "\n") -- Add trailing newline if the buffer has one if #content_lines > 0 and vim.api.nvim_buf_get_option(buffer_id, "eol") then final_content = final_content .. "\n" end -- Close diff windows (unified behavior) if diff_data.new_window and vim.api.nvim_win_is_valid(diff_data.new_window) then vim.api.nvim_win_close(diff_data.new_window, true) end if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then vim.api.nvim_set_current_win(diff_data.target_window) vim.cmd("diffoff") end -- Create MCP-compliant response local result = { content = { { type = "text", text = "FILE_SAVED" }, { type = "text", text = final_content }, }, } diff_data.status = "saved" diff_data.result_content = result -- Resume the coroutine with the result (for deferred response system) if diff_data.resolution_callback then logger.debug("diff", "Resuming coroutine for saved diff", tab_name) diff_data.resolution_callback(result) else logger.debug("diff", "No resolution callback found for saved diff", tab_name) end -- Reload the original file buffer after a delay to ensure Claude CLI has written the file vim.defer_fn(function() local current_diff_data = active_diffs[tab_name] local original_cursor_pos = current_diff_data and current_diff_data.original_cursor_pos M.reload_file_buffers_manual(diff_data.old_file_path, original_cursor_pos) end, 200) -- NOTE: Diff state cleanup is handled by close_tab tool or explicit cleanup calls logger.debug("diff", "Diff saved, awaiting close_tab command for cleanup") end ---Reload file buffers after external changes (called when diff is closed) ---@param file_path string Path to the file that was externally modified ---@param original_cursor_pos table? Original cursor position to restore {row, col} local function reload_file_buffers(file_path, original_cursor_pos) logger.debug("diff", "Reloading buffers for file:", file_path, original_cursor_pos and "(restoring cursor)" or "") local reloaded_count = 0 -- Find and reload any open buffers for this file for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_valid(buf) then local buf_name = vim.api.nvim_buf_get_name(buf) -- Simple string match - if buffer name matches the file path if buf_name == file_path then -- Check if buffer is modified - only reload unmodified buffers for safety local modified = vim.api.nvim_buf_get_option(buf, "modified") logger.debug("diff", "Found matching buffer", buf, "modified:", modified) if not modified then -- Try to find a window displaying this buffer for proper context local win_id = nil for _, win in ipairs(vim.api.nvim_list_wins()) do if vim.api.nvim_win_get_buf(win) == buf then win_id = win break end end if win_id then vim.api.nvim_win_call(win_id, function() vim.cmd("edit") -- Restore original cursor position if we have it if original_cursor_pos then pcall(vim.api.nvim_win_set_cursor, win_id, original_cursor_pos) end end) else vim.api.nvim_buf_call(buf, function() vim.cmd("edit") end) end reloaded_count = reloaded_count + 1 end end end end logger.debug("diff", "Completed buffer reload - reloaded", reloaded_count, "buffers for file:", file_path) end ---Resolve diff as rejected (user closed/rejected) ---@param tab_name string The diff identifier function M._resolve_diff_as_rejected(tab_name) local diff_data = active_diffs[tab_name] if not diff_data or diff_data.status ~= "pending" then return end -- Create MCP-compliant response local result = { content = { { type = "text", text = "DIFF_REJECTED" }, { type = "text", text = tab_name }, }, } diff_data.status = "rejected" diff_data.result_content = result -- Clean up diff state and resources BEFORE resolving to prevent any interference M._cleanup_diff_state(tab_name, "diff rejected") -- Use vim.schedule to ensure the resolution callback happens after all cleanup vim.schedule(function() -- Resume the coroutine with the result (for deferred response system) if diff_data.resolution_callback then logger.debug("diff", "Resuming coroutine for rejected diff", tab_name) diff_data.resolution_callback(result) end end) end ---Register autocmds for a specific diff ---@param tab_name string The diff identifier ---@param new_buffer number New file buffer ID ---@return table autocmd_ids List of autocmd IDs local function register_diff_autocmds(tab_name, new_buffer) local autocmd_ids = {} -- Handle :w command to accept diff changes (replaces both BufWritePost and BufWriteCmd) autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("BufWriteCmd", { group = get_autocmd_group(), buffer = new_buffer, callback = function() logger.debug("diff", "BufWriteCmd (:w) triggered - accepting diff changes for", tab_name) M._resolve_diff_as_saved(tab_name, new_buffer) -- Prevent actual file write since we're handling it through MCP return true end, }) -- Buffer deletion monitoring for rejection (multiple events to catch all deletion methods) -- BufDelete: When buffer is deleted with :bdelete, :bwipeout, etc. autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("BufDelete", { group = get_autocmd_group(), buffer = new_buffer, callback = function() logger.debug("diff", "BufDelete triggered for new buffer", new_buffer, "tab:", tab_name) M._resolve_diff_as_rejected(tab_name) end, }) -- BufUnload: When buffer is unloaded (covers more scenarios) autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("BufUnload", { group = get_autocmd_group(), buffer = new_buffer, callback = function() logger.debug("diff", "BufUnload triggered for new buffer", new_buffer, "tab:", tab_name) M._resolve_diff_as_rejected(tab_name) end, }) -- BufWipeout: When buffer is wiped out completely autocmd_ids[#autocmd_ids + 1] = vim.api.nvim_create_autocmd("BufWipeout", { group = get_autocmd_group(), buffer = new_buffer, callback = function() logger.debug("diff", "BufWipeout triggered for new buffer", new_buffer, "tab:", tab_name) M._resolve_diff_as_rejected(tab_name) end, }) -- Note: We intentionally do NOT monitor old_buffer for deletion -- because it's the actual file buffer and shouldn't trigger diff rejection return autocmd_ids end ---Create diff view from a specific window ---@param target_window number The window to use as base for the diff ---@param old_file_path string Path to the original file ---@param new_buffer number New file buffer ID ---@param tab_name string The diff identifier ---@param is_new_file boolean Whether this is a new file (doesn't exist yet) ---@return table layout Info about the created diff layout function M._create_diff_view_from_window(target_window, old_file_path, new_buffer, tab_name, is_new_file) -- If no target window provided, create a new window in suitable location if not target_window then -- Try to create a new window in the main area vim.cmd("wincmd t") -- Go to top-left vim.cmd("wincmd l") -- Move right (to middle if layout is left|middle|right) local buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) local buftype = vim.api.nvim_buf_get_option(buf, "buftype") local filetype = vim.api.nvim_buf_get_option(buf, "filetype") if buftype == "terminal" or buftype == "prompt" or filetype == "neo-tree" then vim.cmd("vsplit") end target_window = vim.api.nvim_get_current_win() else vim.api.nvim_set_current_win(target_window) end local original_buffer if is_new_file then local empty_buffer = vim.api.nvim_create_buf(false, true) if not empty_buffer or empty_buffer == 0 then local error_msg = "Failed to create empty buffer for new file diff" logger.error("diff", error_msg) error({ code = -32000, message = "Buffer creation failed", data = error_msg, }) end -- Set buffer properties with error handling local success, err = pcall(function() vim.api.nvim_buf_set_name(empty_buffer, old_file_path .. " (NEW FILE)") vim.api.nvim_buf_set_lines(empty_buffer, 0, -1, false, {}) vim.api.nvim_buf_set_option(empty_buffer, "buftype", "nofile") vim.api.nvim_buf_set_option(empty_buffer, "modifiable", false) vim.api.nvim_buf_set_option(empty_buffer, "readonly", true) end) if not success then pcall(vim.api.nvim_buf_delete, empty_buffer, { force = true }) local error_msg = "Failed to configure empty buffer: " .. tostring(err) logger.error("diff", error_msg) error({ code = -32000, message = "Buffer configuration failed", data = error_msg, }) end vim.api.nvim_win_set_buf(target_window, empty_buffer) original_buffer = empty_buffer else vim.cmd("edit " .. vim.fn.fnameescape(old_file_path)) original_buffer = vim.api.nvim_win_get_buf(target_window) end vim.cmd("diffthis") vim.cmd("vsplit") local new_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(new_win, new_buffer) -- Ensure new buffer inherits filetype from original for syntax highlighting (#20) local original_ft = detect_filetype(old_file_path, original_buffer) if original_ft and original_ft ~= "" then vim.api.nvim_set_option_value("filetype", original_ft, { buf = new_buffer }) end vim.cmd("diffthis") vim.cmd("wincmd =") -- Always focus the diff window first for proper visual flow and window arrangement vim.api.nvim_set_current_win(new_win) -- Store diff context in buffer variables for user commands vim.b[new_buffer].claudecode_diff_tab_name = tab_name vim.b[new_buffer].claudecode_diff_new_win = new_win vim.b[new_buffer].claudecode_diff_target_win = target_window -- After all diff setup is complete, optionally return focus to terminal if config and config.diff_opts and config.diff_opts.keep_terminal_focus then vim.schedule(function() local terminal_win = find_claudecode_terminal_window() if terminal_win then vim.api.nvim_set_current_win(terminal_win) end end, 0) end -- Return window information for later storage return { new_window = new_win, target_window = target_window, original_buffer = original_buffer, } end ---Clean up diff state and resources ---@param tab_name string The diff identifier ---@param reason string Reason for cleanup function M._cleanup_diff_state(tab_name, reason) local diff_data = active_diffs[tab_name] if not diff_data then return end -- Clean up autocmds for _, autocmd_id in ipairs(diff_data.autocmd_ids or {}) do pcall(vim.api.nvim_del_autocmd, autocmd_id) end -- Clean up the new buffer only (not the old buffer which is the user's file) if diff_data.new_buffer and vim.api.nvim_buf_is_valid(diff_data.new_buffer) then pcall(vim.api.nvim_buf_delete, diff_data.new_buffer, { force = true }) end -- Close new diff window if still open if diff_data.new_window and vim.api.nvim_win_is_valid(diff_data.new_window) then pcall(vim.api.nvim_win_close, diff_data.new_window, true) end -- Turn off diff mode in target window if it still exists if diff_data.target_window and vim.api.nvim_win_is_valid(diff_data.target_window) then vim.api.nvim_win_call(diff_data.target_window, function() vim.cmd("diffoff") end) end -- Remove from active diffs active_diffs[tab_name] = nil logger.debug("diff", "Cleaned up diff state for", tab_name, "due to:", reason) end ---Clean up all active diffs ---@param reason string Reason for cleanup -- NOTE: This will become a public closeAllDiffTabs tool in the future function M._cleanup_all_active_diffs(reason) for tab_name, _ in pairs(active_diffs) do M._cleanup_diff_state(tab_name, reason) end end ---Set up blocking diff operation with simpler approach ---@param params table Parameters for the diff ---@param resolution_callback function Callback to call when diff resolves function M._setup_blocking_diff(params, resolution_callback) local tab_name = params.tab_name logger.debug("diff", "Setting up diff for:", params.old_file_path) -- Wrap the setup in error handling to ensure cleanup on failure local setup_success, setup_error = pcall(function() -- Step 1: Check if the file exists (allow new files) local old_file_exists = vim.fn.filereadable(params.old_file_path) == 1 local is_new_file = not old_file_exists -- Step 1.5: Check if the file buffer has unsaved changes if old_file_exists then local is_dirty = is_buffer_dirty(params.old_file_path) if is_dirty then error({ code = -32000, message = "Cannot create diff: file has unsaved changes", data = "Please save (:w) or discard (:e!) changes to " .. params.old_file_path .. " before creating diff", }) end end -- Step 2: Find if the file is already open in a buffer (only for existing files) local existing_buffer = nil local target_window = nil if old_file_exists then -- Look for existing buffer with this file for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then local buf_name = vim.api.nvim_buf_get_name(buf) if buf_name == params.old_file_path then existing_buffer = buf break end end end -- Find window containing this buffer (if any) if existing_buffer then for _, win in ipairs(vim.api.nvim_list_wins()) do if vim.api.nvim_win_get_buf(win) == existing_buffer then target_window = win break end end end end -- If no existing buffer/window, find a suitable main editor window if not target_window then target_window = find_main_editor_window() end -- If we still can't find a suitable window, error out if not target_window then error({ code = -32000, message = "No suitable editor window found", data = "Could not find a main editor window to display the diff", }) end -- Step 3: Create scratch buffer for new content local new_buffer = vim.api.nvim_create_buf(false, true) -- unlisted, scratch if new_buffer == 0 then error({ code = -32000, message = "Buffer creation failed", data = "Could not create new content buffer", }) end local new_unique_name = is_new_file and (tab_name .. " (NEW FILE - proposed)") or (tab_name .. " (proposed)") vim.api.nvim_buf_set_name(new_buffer, new_unique_name) local lines = vim.split(params.new_file_contents, "\n") -- Remove trailing empty line if content ended with \n if #lines > 0 and lines[#lines] == "" then table.remove(lines, #lines) end vim.api.nvim_buf_set_lines(new_buffer, 0, -1, false, lines) vim.api.nvim_buf_set_option(new_buffer, "buftype", "acwrite") -- Allows saving but stays as scratch-like vim.api.nvim_buf_set_option(new_buffer, "modifiable", true) -- Step 4: Set up diff view using the target window local diff_info = M._create_diff_view_from_window(target_window, params.old_file_path, new_buffer, tab_name, is_new_file) -- Step 5: Register autocmds for user interaction monitoring local autocmd_ids = register_diff_autocmds(tab_name, new_buffer) -- Step 6: Store diff state -- Save the original cursor position before storing diff state local original_cursor_pos = nil if diff_info.target_window and vim.api.nvim_win_is_valid(diff_info.target_window) then original_cursor_pos = vim.api.nvim_win_get_cursor(diff_info.target_window) end M._register_diff_state(tab_name, { old_file_path = params.old_file_path, new_file_path = params.new_file_path, new_file_contents = params.new_file_contents, new_buffer = new_buffer, new_window = diff_info.new_window, target_window = diff_info.target_window, original_buffer = diff_info.original_buffer, original_cursor_pos = original_cursor_pos, autocmd_ids = autocmd_ids, created_at = vim.fn.localtime(), status = "pending", resolution_callback = resolution_callback, result_content = nil, is_new_file = is_new_file, }) end) -- End of pcall -- Handle setup errors if not setup_success then local error_msg if type(setup_error) == "table" and setup_error.message then -- Handle structured error objects error_msg = "Failed to setup diff operation: " .. setup_error.message if setup_error.data then error_msg = error_msg .. " (" .. setup_error.data .. ")" end else -- Handle string errors or other types error_msg = "Failed to setup diff operation: " .. tostring(setup_error) end -- Clean up any partial state that might have been created if active_diffs[tab_name] then M._cleanup_diff_state(tab_name, "setup failed") end -- Re-throw the error for MCP compliance error({ code = -32000, message = "Diff setup failed", data = error_msg, }) end end ---Blocking diff operation for MCP compliance ---@param old_file_path string Path to the original file ---@param new_file_path string Path to the new file (used for naming) ---@param new_file_contents string Contents of the new file ---@param tab_name string Name for the diff tab/view ---@return table response MCP-compliant response with content array function M.open_diff_blocking(old_file_path, new_file_path, new_file_contents, tab_name) -- Check for existing diff with same tab_name if active_diffs[tab_name] then -- Resolve the existing diff as rejected before replacing M._resolve_diff_as_rejected(tab_name) end -- Set up blocking diff operation local co, is_main = coroutine.running() if not co or is_main then error({ code = -32000, message = "Internal server error", data = "openDiff must run in coroutine context", }) end logger.debug("diff", "Starting diff setup for", tab_name) -- Use native diff implementation local success, err = pcall(M._setup_blocking_diff, { old_file_path = old_file_path, new_file_path = new_file_path, new_file_contents = new_file_contents, tab_name = tab_name, }, function(result) -- Resume the coroutine with the result local resume_success, resume_result = coroutine.resume(co, result) if resume_success then -- Use the global response sender to avoid module reloading issues local co_key = tostring(co) if _G.claude_deferred_responses and _G.claude_deferred_responses[co_key] then _G.claude_deferred_responses[co_key](resume_result) _G.claude_deferred_responses[co_key] = nil else logger.error("diff", "No global response sender found for coroutine:", co_key) end else logger.error("diff", "Coroutine failed:", tostring(resume_result)) local co_key = tostring(co) if _G.claude_deferred_responses and _G.claude_deferred_responses[co_key] then _G.claude_deferred_responses[co_key]({ error = { code = -32603, message = "Internal error", data = "Coroutine failed: " .. tostring(resume_result), }, }) _G.claude_deferred_responses[co_key] = nil end end end) if not success then local error_msg if type(err) == "table" and err.message then error_msg = err.message if err.data then error_msg = error_msg .. " - " .. err.data end else error_msg = tostring(err) end logger.error("diff", "Diff setup failed for", '"' .. tab_name .. '"', "error:", error_msg) -- If the error is already structured, propagate it directly if type(err) == "table" and err.code then error(err) else error({ code = -32000, message = "Error setting up diff", data = tostring(err), }) end end -- Yield and wait indefinitely for user interaction - the resolve functions will resume us local user_action_result = coroutine.yield() logger.debug("diff", "User action completed for", tab_name) -- Return the result directly - this will be sent by the deferred response system return user_action_result end -- Set up global autocmds for shutdown handling vim.api.nvim_create_autocmd("VimLeavePre", { group = get_autocmd_group(), callback = function() M._cleanup_all_active_diffs("shutdown") end, }) ---Close diff by tab name (used by close_tab tool) ---@param tab_name string The diff identifier ---@return boolean success True if diff was found and closed function M.close_diff_by_tab_name(tab_name) local diff_data = active_diffs[tab_name] if not diff_data then return false end -- If the diff was already saved, reload file buffers and clean up if diff_data.status == "saved" then -- Claude Code CLI has written the file, reload any open buffers if diff_data.old_file_path then -- Add a small delay to ensure Claude CLI has finished writing the file vim.defer_fn(function() M.reload_file_buffers_manual(diff_data.old_file_path, diff_data.original_cursor_pos) end, 100) -- 100ms delay end M._cleanup_diff_state(tab_name, "diff tab closed after save") return true end -- If still pending, treat as rejection if diff_data.status == "pending" then M._resolve_diff_as_rejected(tab_name) return true end return false end --Test helper function (only for testing) function M._get_active_diffs() return active_diffs end --Manual buffer reload function for testing/debugging function M.reload_file_buffers_manual(file_path, original_cursor_pos) return reload_file_buffers(file_path, original_cursor_pos) end ---Accept the current diff (user command version) ---This function reads the diff context from buffer variables function M.accept_current_diff() local current_buffer = vim.api.nvim_get_current_buf() local tab_name = vim.b[current_buffer].claudecode_diff_tab_name if not tab_name then vim.notify("No active diff found in current buffer", vim.log.levels.WARN) return end M._resolve_diff_as_saved(tab_name, current_buffer) end ---Deny/reject the current diff (user command version) ---This function reads the diff context from buffer variables function M.deny_current_diff() local current_buffer = vim.api.nvim_get_current_buf() local tab_name = vim.b[current_buffer].claudecode_diff_tab_name local new_win = vim.b[current_buffer].claudecode_diff_new_win local target_window = vim.b[current_buffer].claudecode_diff_target_win if not tab_name then vim.notify("No active diff found in current buffer", vim.log.levels.WARN) return end -- Close windows and clean up (same logic as the original keymap) if new_win and vim.api.nvim_win_is_valid(new_win) then vim.api.nvim_win_close(new_win, true) end if target_window and vim.api.nvim_win_is_valid(target_window) then vim.api.nvim_set_current_win(target_window) vim.cmd("diffoff") end M._resolve_diff_as_rejected(tab_name) end return M ================================================ FILE: lua/claudecode/init.lua ================================================ ---@brief [[ --- Claude Code Neovim Integration --- This plugin integrates Claude Code CLI with Neovim, enabling --- seamless AI-assisted coding experiences directly in Neovim. ---@brief ]] ---@module 'claudecode' local M = {} local logger = require("claudecode.logger") -- Types ---@class ClaudeCode.Version ---@field major integer ---@field minor integer ---@field patch integer ---@field prerelease? string ---@field string fun(self: ClaudeCode.Version):string -- Narrow facade of the server module used by this file ---@class ClaudeCode.ServerFacade ---@field start fun(config: ClaudeCode.Config, auth_token: string|nil): boolean, number|string ---@field stop fun(): boolean, string|nil ---@field broadcast fun(method: string, params: table|nil): boolean ---@field get_status fun(): { running: boolean, port: integer|nil, client_count: integer, clients?: table } -- State type for this module ---@class ClaudeCode.State ---@field config ClaudeCode.Config ---@field server ClaudeCode.ServerFacade|nil ---@field port integer|nil ---@field auth_token string|nil ---@field initialized boolean ---@field mention_queue table[] ---@field mention_timer table|nil ---@field connection_timer table|nil --- Current plugin version ---@type ClaudeCode.Version M.version = { major = 0, minor = 2, patch = 0, prerelease = nil, string = function(self) local version = string.format("%d.%d.%d", self.major, self.minor, self.patch) if self.prerelease then version = version .. "-" .. self.prerelease end return version end, } -- Module state ---@type ClaudeCode.State M.state = { config = require("claudecode.config").defaults, server = nil, port = nil, auth_token = nil, initialized = false, mention_queue = {}, mention_timer = nil, connection_timer = nil, } ---Check if Claude Code is connected to WebSocket server ---@return boolean connected Whether Claude Code has active connections function M.is_claude_connected() if not M.state.server then return false end local server_module = require("claudecode.server.init") local status = server_module.get_status() return status.running and status.client_count > 0 end ---Clear the mention queue and stop any pending timer local function clear_mention_queue() -- Initialize mention_queue if it doesn't exist (for test compatibility) if not M.state.mention_queue then M.state.mention_queue = {} else if #M.state.mention_queue > 0 then logger.debug("queue", "Clearing " .. #M.state.mention_queue .. " queued @ mentions") end M.state.mention_queue = {} end if M.state.mention_timer then M.state.mention_timer:stop() M.state.mention_timer:close() M.state.mention_timer = nil end end ---Process mentions when Claude is connected (debounced mode) local function process_connected_mentions() -- Reset the debounce timer if M.state.mention_timer then M.state.mention_timer:stop() M.state.mention_timer:close() end -- Set a new timer to process the queue after 50ms of inactivity M.state.mention_timer = vim.loop.new_timer() local debounce_delay = math.max(10, 50) -- Minimum 10ms debounce, 50ms for batching -- Use vim.schedule_wrap if available, otherwise fallback to vim.schedule + function call local wrapped_function = vim.schedule_wrap and vim.schedule_wrap(M.process_mention_queue) or function() vim.schedule(M.process_mention_queue) end M.state.mention_timer:start(debounce_delay, 0, wrapped_function) end ---Start connection timeout timer if not already started local function start_connection_timeout_if_needed() if not M.state.connection_timer then M.state.connection_timer = vim.loop.new_timer() M.state.connection_timer:start(M.state.config.connection_timeout, 0, function() vim.schedule(function() if #M.state.mention_queue > 0 then logger.error("queue", "Connection timeout - clearing " .. #M.state.mention_queue .. " queued @ mentions") clear_mention_queue() end end) end) end end ---Add @ mention to queue ---@param file_path string The file path to mention ---@param start_line number|nil Optional start line ---@param end_line number|nil Optional end line local function queue_mention(file_path, start_line, end_line) -- Initialize mention_queue if it doesn't exist (for test compatibility) if not M.state.mention_queue then M.state.mention_queue = {} end local mention_data = { file_path = file_path, start_line = start_line, end_line = end_line, timestamp = vim.loop.now(), } table.insert(M.state.mention_queue, mention_data) logger.debug("queue", "Queued @ mention: " .. file_path .. " (queue size: " .. #M.state.mention_queue .. ")") -- Process based on connection state if M.is_claude_connected() then -- Connected: Use debounced processing (old broadcast_queue behavior) process_connected_mentions() else -- Disconnected: Start connection timeout timer (old queued_mentions behavior) start_connection_timeout_if_needed() end end ---Process the mention queue (handles both connected and disconnected modes) ---@param from_new_connection boolean|nil Whether this is triggered by a new connection (adds delay) function M.process_mention_queue(from_new_connection) -- Initialize mention_queue if it doesn't exist (for test compatibility) if not M.state.mention_queue then M.state.mention_queue = {} return end if #M.state.mention_queue == 0 then return end if not M.is_claude_connected() then -- Still disconnected, wait for connection logger.debug("queue", "Claude not connected, keeping " .. #M.state.mention_queue .. " mentions queued") return end local mentions_to_send = vim.deepcopy(M.state.mention_queue) M.state.mention_queue = {} -- Clear queue -- Stop any existing timer if M.state.mention_timer then M.state.mention_timer:stop() M.state.mention_timer:close() M.state.mention_timer = nil end -- Stop connection timer since we're now connected if M.state.connection_timer then M.state.connection_timer:stop() M.state.connection_timer:close() M.state.connection_timer = nil end logger.debug("queue", "Processing " .. #mentions_to_send .. " queued @ mentions") -- Send mentions with 10ms delay between each to prevent WebSocket buffer overflow local function send_mention_sequential(index) if index > #mentions_to_send then logger.debug("queue", "All queued mentions sent successfully") return end local mention = mentions_to_send[index] -- Check if mention has expired (same timeout logic as old system) local current_time = vim.loop.now() if (current_time - mention.timestamp) > M.state.config.queue_timeout then logger.debug("queue", "Skipped expired @ mention: " .. mention.file_path) else -- Directly broadcast without going through the queue system to avoid infinite recursion local params = { filePath = mention.file_path, lineStart = mention.start_line, lineEnd = mention.end_line, } local broadcast_success = M.state.server.broadcast("at_mentioned", params) if broadcast_success then logger.debug("queue", "Sent queued @ mention: " .. mention.file_path) else logger.error("queue", "Failed to send queued @ mention: " .. mention.file_path) end end -- Process next mention with delay if index < #mentions_to_send then vim.defer_fn(function() send_mention_sequential(index + 1) end, 10) -- 10ms delay between mentions end end -- Apply delay for new connections, send immediately for debounced processing if #mentions_to_send > 0 then if from_new_connection then -- Wait for connection_wait_delay when processing queue after new connection vim.defer_fn(function() send_mention_sequential(1) end, M.state.config.connection_wait_delay) else -- Send immediately for debounced processing (Claude already connected) send_mention_sequential(1) end end end ---Show terminal if Claude is connected and it's not already visible ---@return boolean success Whether terminal was shown or was already visible function M._ensure_terminal_visible_if_connected() if not M.is_claude_connected() then return false end local terminal = require("claudecode.terminal") local active_bufnr = terminal.get_active_terminal_bufnr and terminal.get_active_terminal_bufnr() if not active_bufnr then return false end local bufinfo = vim.fn.getbufinfo(active_bufnr)[1] local is_visible = bufinfo and #bufinfo.windows > 0 if not is_visible then terminal.simple_toggle() end return true end ---Send @ mention to Claude Code, handling connection state automatically ---@param file_path string The file path to send ---@param start_line number|nil Start line (0-indexed for Claude) ---@param end_line number|nil End line (0-indexed for Claude) ---@param context string|nil Context for logging ---@return boolean success Whether the operation was successful ---@return string|nil error Error message if failed function M.send_at_mention(file_path, start_line, end_line, context) context = context or "command" if not M.state.server then logger.error(context, "Claude Code integration is not running") return false, "Claude Code integration is not running" end -- Check if Claude Code is connected if M.is_claude_connected() then -- Claude is connected, send immediately and ensure terminal is visible local success, error_msg = M._broadcast_at_mention(file_path, start_line, end_line) if success then local terminal = require("claudecode.terminal") terminal.ensure_visible() end return success, error_msg else -- Claude not connected, queue the mention and launch terminal queue_mention(file_path, start_line, end_line) -- Launch terminal with Claude Code local terminal = require("claudecode.terminal") terminal.open() logger.debug(context, "Queued @ mention and launched Claude Code: " .. file_path) return true, nil end end ---Set up the plugin with user configuration ---@param opts ClaudeCode.Config|nil Optional configuration table to override defaults. ---@return table module The plugin module function M.setup(opts) opts = opts or {} local config = require("claudecode.config") M.state.config = config.apply(opts) -- vim.g.claudecode_user_config is no longer needed as config values are passed directly. logger.setup(M.state.config) -- Setup terminal module: always try to call setup to pass terminal_cmd and env, -- even if terminal_opts (for split_side etc.) are not provided. local terminal_setup_ok, terminal_module = pcall(require, "claudecode.terminal") if terminal_setup_ok then -- Guard in case tests or user replace the module with a minimal stub without `setup`. if type(terminal_module.setup) == "function" then -- terminal_opts might be nil, which the setup function should handle gracefully. terminal_module.setup(opts.terminal, M.state.config.terminal_cmd, M.state.config.env) end else logger.error("init", "Failed to load claudecode.terminal module for setup.") end local diff = require("claudecode.diff") diff.setup(M.state.config) if M.state.config.auto_start then M.start(false) -- Suppress notification on auto-start end M._create_commands() vim.api.nvim_create_autocmd("VimLeavePre", { group = vim.api.nvim_create_augroup("ClaudeCodeShutdown", { clear = true }), callback = function() if M.state.server then M.stop() else -- Clear queue even if server isn't running clear_mention_queue() end end, desc = "Automatically stop Claude Code integration when exiting Neovim", }) M.state.initialized = true return M end ---Start the Claude Code integration ---@param show_startup_notification? boolean Whether to show a notification upon successful startup (defaults to true) ---@return boolean success Whether the operation was successful ---@return number|string port_or_error The WebSocket port if successful, or error message if failed function M.start(show_startup_notification) if show_startup_notification == nil then show_startup_notification = true end if M.state.server then local msg = "Claude Code integration is already running on port " .. tostring(M.state.port) logger.warn("init", msg) return false, "Already running" end local server = require("claudecode.server.init") local lockfile = require("claudecode.lockfile") -- Generate auth token first so we can pass it to the server local auth_token local auth_success, auth_result = pcall(function() return lockfile.generate_auth_token() end) if not auth_success then local error_msg = "Failed to generate authentication token: " .. (auth_result or "unknown error") logger.error("init", error_msg) return false, error_msg end auth_token = auth_result -- Validate the generated auth token if not auth_token or type(auth_token) ~= "string" or #auth_token < 10 then local error_msg = "Invalid authentication token generated" logger.error("init", error_msg) return false, error_msg end local success, result = server.start(M.state.config, auth_token) if not success then local error_msg = "Failed to start Claude Code server: " .. (result or "unknown error") if result and result:find("auth") then error_msg = error_msg .. " (authentication related)" end logger.error("init", error_msg) return false, error_msg end M.state.server = server M.state.port = tonumber(result) M.state.auth_token = auth_token local lock_success, lock_result, returned_auth_token = lockfile.create(M.state.port, auth_token) if not lock_success then server.stop() M.state.server = nil M.state.port = nil M.state.auth_token = nil local error_msg = "Failed to create lock file: " .. (lock_result or "unknown error") if lock_result and lock_result:find("auth") then error_msg = error_msg .. " (authentication token issue)" end logger.error("init", error_msg) return false, error_msg end -- Verify that the auth token in the lock file matches what we generated if returned_auth_token ~= auth_token then server.stop() M.state.server = nil M.state.port = nil M.state.auth_token = nil local error_msg = "Authentication token mismatch between server and lock file" logger.error("init", error_msg) return false, error_msg end if M.state.config.track_selection then local selection = require("claudecode.selection") selection.enable(M.state.server, M.state.config.visual_demotion_delay_ms) end if show_startup_notification then logger.info("init", "Claude Code integration started on port " .. tostring(M.state.port)) end return true, M.state.port end ---Stop the Claude Code integration ---@return boolean success Whether the operation was successful ---@return string|nil error Error message if operation failed function M.stop() if not M.state.server then logger.warn("init", "Claude Code integration is not running") return false, "Not running" end local lockfile = require("claudecode.lockfile") local lock_success, lock_error = lockfile.remove(M.state.port) if not lock_success then logger.warn("init", "Failed to remove lock file: " .. lock_error) -- Continue with shutdown even if lock file removal fails end if M.state.config.track_selection then local selection = require("claudecode.selection") selection.disable() end local success, error = M.state.server.stop() if not success then logger.error("init", "Failed to stop Claude Code integration: " .. error) return false, error end M.state.server = nil M.state.port = nil M.state.auth_token = nil -- Clear any queued @ mentions when server stops clear_mention_queue() logger.info("init", "Claude Code integration stopped") return true end ---Set up user commands ---@private function M._create_commands() vim.api.nvim_create_user_command("ClaudeCodeStart", function() M.start() end, { desc = "Start Claude Code integration", }) vim.api.nvim_create_user_command("ClaudeCodeStop", function() M.stop() end, { desc = "Stop Claude Code integration", }) vim.api.nvim_create_user_command("ClaudeCodeStatus", function() if M.state.server and M.state.port then logger.info("command", "Claude Code integration is running on port " .. tostring(M.state.port)) else logger.info("command", "Claude Code integration is not running") end end, { desc = "Show Claude Code integration status", }) ---@param file_paths table List of file paths to add ---@param options table|nil Optional settings: { delay?: number, show_summary?: boolean, context?: string } ---@return number success_count Number of successfully added files ---@return number total_count Total number of files attempted local function add_paths_to_claude(file_paths, options) options = options or {} local delay = options.delay or 0 local show_summary = options.show_summary ~= false local context = options.context or "command" if not file_paths or #file_paths == 0 then return 0, 0 end local success_count = 0 local total_count = #file_paths if delay > 0 then local function send_files_sequentially(index) if index > total_count then if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) end if total_count > success_count then if success_count > 0 then logger.warn(context, message) else logger.error(context, message) end elseif success_count > 0 then logger.info(context, message) else logger.debug(context, message) end end return end local file_path = file_paths[index] local success, error_msg = M.send_at_mention(file_path, nil, nil, context) if success then success_count = success_count + 1 else logger.error(context, "Failed to add file: " .. file_path .. " - " .. (error_msg or "unknown error")) end if index < total_count then vim.defer_fn(function() send_files_sequentially(index + 1) end, delay) else if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) end if total_count > success_count then if success_count > 0 then logger.warn(context, message) else logger.error(context, message) end elseif success_count > 0 then logger.info(context, message) else logger.debug(context, message) end end end end send_files_sequentially(1) else for _, file_path in ipairs(file_paths) do local success, error_msg = M.send_at_mention(file_path, nil, nil, context) if success then success_count = success_count + 1 else logger.error(context, "Failed to add file: " .. file_path .. " - " .. (error_msg or "unknown error")) end end if show_summary and success_count > 0 then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) end logger.debug(context, message) end end return success_count, total_count end local function handle_send_normal(opts) local current_ft = (vim.bo and vim.bo.filetype) or "" local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or "" local is_tree_buffer = current_ft == "NvimTree" or current_ft == "neo-tree" or current_ft == "oil" or current_ft == "minifiles" or string.match(current_bufname, "neo%-tree") or string.match(current_bufname, "NvimTree") or string.match(current_bufname, "minifiles://") if is_tree_buffer then local integrations = require("claudecode.integrations") local files, error = integrations.get_selected_files_from_tree() if error then logger.error("command", "ClaudeCodeSend->TreeAdd: " .. error) return end if not files or #files == 0 then logger.warn("command", "ClaudeCodeSend->TreeAdd: No files selected") return end add_paths_to_claude(files, { context = "ClaudeCodeSend->TreeAdd" }) return end local selection_module_ok, selection_module = pcall(require, "claudecode.selection") if selection_module_ok then -- Pass range information if available (for :'<,'> commands) local line1, line2 = nil, nil if opts and opts.range and opts.range > 0 then line1, line2 = opts.line1, opts.line2 end local sent_successfully = selection_module.send_at_mention_for_visual_selection(line1, line2) if sent_successfully then -- Exit any potential visual mode (for consistency) pcall(function() if vim.api and vim.api.nvim_feedkeys then local esc = vim.api.nvim_replace_termcodes("", true, false, true) vim.api.nvim_feedkeys(esc, "i", true) end end) end else logger.error("command", "ClaudeCodeSend: Failed to load selection module.") end end local function handle_send_visual(visual_data, opts) -- Check if we're in a tree buffer first local current_ft = (vim.bo and vim.bo.filetype) or "" local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or "" local is_tree_buffer = current_ft == "NvimTree" or current_ft == "neo-tree" or current_ft == "oil" or current_ft == "minifiles" or string.match(current_bufname, "neo%-tree") or string.match(current_bufname, "NvimTree") or string.match(current_bufname, "minifiles://") if is_tree_buffer then local integrations = require("claudecode.integrations") local files, error -- For mini.files, try to get the range from visual marks if current_ft == "minifiles" or string.match(current_bufname, "minifiles://") then local start_line = vim.fn.line("'<") local end_line = vim.fn.line("'>") if start_line > 0 and end_line > 0 and start_line <= end_line then -- Use range-based selection for mini.files files, error = integrations._get_mini_files_selection_with_range(start_line, end_line) else -- Fall back to regular method files, error = integrations.get_selected_files_from_tree() end else files, error = integrations.get_selected_files_from_tree() end if error then logger.error("command", "ClaudeCodeSend_visual->TreeAdd: " .. error) return end if not files or #files == 0 then logger.warn("command", "ClaudeCodeSend_visual->TreeAdd: No files selected") return end add_paths_to_claude(files, { context = "ClaudeCodeSend_visual->TreeAdd" }) return end -- Fall back to old visual selection logic for non-tree buffers if visual_data then local visual_commands = require("claudecode.visual_commands") local files, error = visual_commands.get_files_from_visual_selection(visual_data) if not error and files and #files > 0 then local success_count = add_paths_to_claude(files, { delay = 10, context = "ClaudeCodeSend_visual", show_summary = false, }) if success_count > 0 then local message = success_count == 1 and "Added 1 file to Claude context from visual selection" or string.format("Added %d files to Claude context from visual selection", success_count) logger.debug("command", message) end return end end -- Handle regular text selection using range from visual mode local selection_module_ok, selection_module = pcall(require, "claudecode.selection") if not selection_module_ok then return end -- Use the marks left by visual mode instead of trying to get current visual selection local line1, line2 = vim.fn.line("'<"), vim.fn.line("'>") if line1 and line2 and line1 > 0 and line2 > 0 then selection_module.send_at_mention_for_visual_selection(line1, line2) else selection_module.send_at_mention_for_visual_selection() end end local visual_commands = require("claudecode.visual_commands") local unified_send_handler = visual_commands.create_visual_command_wrapper(handle_send_normal, handle_send_visual) vim.api.nvim_create_user_command("ClaudeCodeSend", unified_send_handler, { desc = "Send current visual selection as an at_mention to Claude Code (supports tree visual selection)", range = true, }) local function handle_tree_add_normal() if not M.state.server then logger.error("command", "ClaudeCodeTreeAdd: Claude Code integration is not running.") return end local integrations = require("claudecode.integrations") local files, error = integrations.get_selected_files_from_tree() if error then logger.error("command", "ClaudeCodeTreeAdd: " .. error) return end if not files or #files == 0 then logger.warn("command", "ClaudeCodeTreeAdd: No files selected") return end -- Use connection-aware broadcasting for each file local success_count = 0 local total_count = #files for _, file_path in ipairs(files) do local success, error_msg = M.send_at_mention(file_path, nil, nil, "ClaudeCodeTreeAdd") if success then success_count = success_count + 1 else logger.error( "command", "ClaudeCodeTreeAdd: Failed to add file: " .. file_path .. " - " .. (error_msg or "unknown error") ) end end if success_count == 0 then logger.error("command", "ClaudeCodeTreeAdd: Failed to add any files") elseif success_count < total_count then local message = string.format("Added %d/%d files to Claude context", success_count, total_count) logger.debug("command", message) else local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) logger.debug("command", message) end end local function handle_tree_add_visual(visual_data) if not M.state.server then logger.error("command", "ClaudeCodeTreeAdd_visual: Claude Code integration is not running.") return end local visual_cmd_module = require("claudecode.visual_commands") local files, error = visual_cmd_module.get_files_from_visual_selection(visual_data) if error then logger.error("command", "ClaudeCodeTreeAdd_visual: " .. error) return end if not files or #files == 0 then logger.warn("command", "ClaudeCodeTreeAdd_visual: No files selected in visual range") return end -- Use connection-aware broadcasting for each file local success_count = 0 local total_count = #files for _, file_path in ipairs(files) do local success, error_msg = M.send_at_mention(file_path, nil, nil, "ClaudeCodeTreeAdd_visual") if success then success_count = success_count + 1 else logger.error( "command", "ClaudeCodeTreeAdd_visual: Failed to add file: " .. file_path .. " - " .. (error_msg or "unknown error") ) end end if success_count > 0 then local message = success_count == 1 and "Added 1 file to Claude context from visual selection" or string.format("Added %d files to Claude context from visual selection", success_count) logger.debug("command", message) if success_count < total_count then logger.warn("command", string.format("Added %d/%d files from visual selection", success_count, total_count)) end else logger.error("command", "ClaudeCodeTreeAdd_visual: Failed to add any files from visual selection") end end local unified_tree_add_handler = visual_commands.create_visual_command_wrapper(handle_tree_add_normal, handle_tree_add_visual) vim.api.nvim_create_user_command("ClaudeCodeTreeAdd", unified_tree_add_handler, { desc = "Add selected file(s) from tree explorer to Claude Code context (supports visual selection)", }) vim.api.nvim_create_user_command("ClaudeCodeAdd", function(opts) if not M.state.server then logger.error("command", "ClaudeCodeAdd: Claude Code integration is not running.") return end if not opts.args or opts.args == "" then logger.error("command", "ClaudeCodeAdd: No file path provided") return end local args = vim.split(opts.args, "%s+") local file_path = args[1] local start_line = args[2] and tonumber(args[2]) or nil local end_line = args[3] and tonumber(args[3]) or nil if #args > 3 then logger.error( "command", "ClaudeCodeAdd: Too many arguments. Usage: ClaudeCodeAdd [start-line] [end-line]" ) return end if args[2] and not start_line then logger.error("command", "ClaudeCodeAdd: Invalid start line number: " .. args[2]) return end if args[3] and not end_line then logger.error("command", "ClaudeCodeAdd: Invalid end line number: " .. args[3]) return end if start_line and start_line < 1 then logger.error("command", "ClaudeCodeAdd: Start line must be positive: " .. start_line) return end if end_line and end_line < 1 then logger.error("command", "ClaudeCodeAdd: End line must be positive: " .. end_line) return end if start_line and end_line and start_line > end_line then logger.error( "command", "ClaudeCodeAdd: Start line (" .. start_line .. ") must be <= end line (" .. end_line .. ")" ) return end file_path = vim.fn.expand(file_path) if vim.fn.filereadable(file_path) == 0 and vim.fn.isdirectory(file_path) == 0 then logger.error("command", "ClaudeCodeAdd: File or directory does not exist: " .. file_path) return end local claude_start_line = start_line and (start_line - 1) or nil local claude_end_line = end_line and (end_line - 1) or nil local success, error_msg = M.send_at_mention(file_path, claude_start_line, claude_end_line, "ClaudeCodeAdd") if not success then logger.error("command", "ClaudeCodeAdd: " .. (error_msg or "Failed to add file")) else local message = "ClaudeCodeAdd: Successfully added " .. file_path if start_line or end_line then if start_line and end_line then message = message .. " (lines " .. start_line .. "-" .. end_line .. ")" elseif start_line then message = message .. " (from line " .. start_line .. ")" end end logger.debug("command", message) end end, { nargs = "+", complete = "file", desc = "Add specified file or directory to Claude Code context with optional line range", }) local terminal_ok, terminal = pcall(require, "claudecode.terminal") if terminal_ok then vim.api.nvim_create_user_command("ClaudeCode", function(opts) local current_mode = vim.fn.mode() if current_mode == "v" or current_mode == "V" or current_mode == "\22" then vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", false) end local cmd_args = opts.args and opts.args ~= "" and opts.args or nil terminal.simple_toggle({}, cmd_args) end, { nargs = "*", desc = "Toggle the Claude Code terminal window (simple show/hide) with optional arguments", }) vim.api.nvim_create_user_command("ClaudeCodeFocus", function(opts) local current_mode = vim.fn.mode() if current_mode == "v" or current_mode == "V" or current_mode == "\22" then vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", false) end local cmd_args = opts.args and opts.args ~= "" and opts.args or nil terminal.focus_toggle({}, cmd_args) end, { nargs = "*", desc = "Smart focus/toggle Claude Code terminal (switches to terminal if not focused, hides if focused)", }) vim.api.nvim_create_user_command("ClaudeCodeOpen", function(opts) local cmd_args = opts.args and opts.args ~= "" and opts.args or nil terminal.open({}, cmd_args) end, { nargs = "*", desc = "Open the Claude Code terminal window with optional arguments", }) vim.api.nvim_create_user_command("ClaudeCodeClose", function() terminal.close() end, { desc = "Close the Claude Code terminal window", }) else logger.error( "init", "Terminal module not found. Terminal commands (ClaudeCode, ClaudeCodeOpen, ClaudeCodeClose) not registered." ) end -- Diff management commands vim.api.nvim_create_user_command("ClaudeCodeDiffAccept", function() local diff = require("claudecode.diff") diff.accept_current_diff() end, { desc = "Accept the current diff changes", }) vim.api.nvim_create_user_command("ClaudeCodeDiffDeny", function() local diff = require("claudecode.diff") diff.deny_current_diff() end, { desc = "Deny/reject the current diff changes", }) vim.api.nvim_create_user_command("ClaudeCodeSelectModel", function(opts) local cmd_args = opts.args and opts.args ~= "" and opts.args or nil M.open_with_model(cmd_args) end, { nargs = "*", desc = "Select and open Claude terminal with chosen model and optional arguments", }) end M.open_with_model = function(additional_args) local models = M.state.config.models if not models or #models == 0 then logger.error("command", "No models configured for selection") return end vim.ui.select(models, { prompt = "Select Claude model:", format_item = function(item) return item.name end, }, function(choice) if not choice then return -- User cancelled end if not choice.value or type(choice.value) ~= "string" then logger.error("command", "Invalid model value selected") return end local model_arg = "--model " .. choice.value local final_args = additional_args and (model_arg .. " " .. additional_args) or model_arg vim.cmd("ClaudeCode " .. final_args) end) end ---Get version information ---@return { version: string, major: integer, minor: integer, patch: integer, prerelease: string|nil } function M.get_version() return { version = M.version:string(), major = M.version.major, minor = M.version.minor, patch = M.version.patch, prerelease = M.version.prerelease, } end ---Format file path for at mention (exposed for testing) ---@param file_path string The file path to format ---@return string formatted_path The formatted path ---@return boolean is_directory Whether the path is a directory function M._format_path_for_at_mention(file_path) -- Input validation if not file_path or type(file_path) ~= "string" or file_path == "" then error("format_path_for_at_mention: file_path must be a non-empty string") end -- Only check path existence in production (not tests) -- This allows tests to work with mock paths while still providing validation in real usage if not package.loaded["busted"] then if vim.fn.filereadable(file_path) == 0 and vim.fn.isdirectory(file_path) == 0 then error("format_path_for_at_mention: path does not exist: " .. file_path) end end local is_directory = vim.fn.isdirectory(file_path) == 1 local formatted_path = file_path if is_directory then local cwd = vim.fn.getcwd() if string.find(file_path, cwd, 1, true) == 1 then local relative_path = string.sub(file_path, #cwd + 2) if relative_path ~= "" then formatted_path = relative_path else formatted_path = "./" end end if not string.match(formatted_path, "/$") then formatted_path = formatted_path .. "/" end else local cwd = vim.fn.getcwd() if string.find(file_path, cwd, 1, true) == 1 then local relative_path = string.sub(file_path, #cwd + 2) if relative_path ~= "" then formatted_path = relative_path end end end return formatted_path, is_directory end ---Test helper functions (exposed for testing) function M._broadcast_at_mention(file_path, start_line, end_line) if not M.state.server then return false, "Claude Code integration is not running" end -- Safely format the path and handle validation errors local formatted_path, is_directory local format_success, format_result, is_dir_result = pcall(M._format_path_for_at_mention, file_path) if not format_success then return false, format_result -- format_result contains the error message end formatted_path, is_directory = format_result, is_dir_result if is_directory and (start_line or end_line) then logger.debug("command", "Line numbers ignored for directory: " .. formatted_path) start_line = nil end_line = nil end local params = { filePath = formatted_path, lineStart = start_line, lineEnd = end_line, } -- For tests or when explicitly configured, broadcast immediately without queuing if (M.state.config and M.state.config.disable_broadcast_debouncing) or (package.loaded["busted"] and not (M.state.config and M.state.config.enable_broadcast_debouncing_in_tests)) then local broadcast_success = M.state.server.broadcast("at_mentioned", params) if broadcast_success then return true, nil else local error_msg = "Failed to broadcast " .. (is_directory and "directory" or "file") .. " " .. formatted_path logger.error("command", error_msg) return false, error_msg end end -- Use mention queue system for debounced broadcasting queue_mention(formatted_path, start_line, end_line) -- Always return success since we're queuing the message -- The actual broadcast result will be logged in the queue processing return true, nil end function M._add_paths_to_claude(file_paths, options) options = options or {} local delay = options.delay or 0 local show_summary = options.show_summary ~= false local context = options.context or "command" local batch_size = options.batch_size or 10 local max_files = options.max_files or 100 if not file_paths or #file_paths == 0 then return 0, 0 end if #file_paths > max_files then logger.warn(context, string.format("Too many files selected (%d), limiting to %d", #file_paths, max_files)) local limited_paths = {} for i = 1, max_files do limited_paths[i] = file_paths[i] end file_paths = limited_paths end local success_count = 0 local total_count = #file_paths if delay > 0 then local function send_batch(start_index) if start_index > total_count then if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) end if total_count > success_count then if success_count > 0 then logger.warn(context, message) else logger.error(context, message) end elseif success_count > 0 then logger.info(context, message) else logger.debug(context, message) end end return end -- Process a batch of files local end_index = math.min(start_index + batch_size - 1, total_count) local batch_success = 0 for i = start_index, end_index do local file_path = file_paths[i] local success, error_msg = M._broadcast_at_mention(file_path) if success then success_count = success_count + 1 batch_success = batch_success + 1 else logger.error(context, "Failed to add file: " .. file_path .. " - " .. (error_msg or "unknown error")) end end logger.debug( context, string.format( "Processed batch %d-%d: %d/%d successful", start_index, end_index, batch_success, end_index - start_index + 1 ) ) if end_index < total_count then vim.defer_fn(function() send_batch(end_index + 1) end, delay) else if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) end if total_count > success_count then if success_count > 0 then logger.warn(context, message) else logger.error(context, message) end elseif success_count > 0 then logger.info(context, message) else logger.debug(context, message) end end end end send_batch(1) else local progress_interval = math.max(1, math.floor(total_count / 10)) for i, file_path in ipairs(file_paths) do local success, error_msg = M._broadcast_at_mention(file_path) if success then success_count = success_count + 1 else logger.error(context, "Failed to add file: " .. file_path .. " - " .. (error_msg or "unknown error")) end if total_count > 20 and i % progress_interval == 0 then logger.debug( context, string.format("Progress: %d/%d files processed (%d successful)", i, total_count, success_count) ) end end if show_summary then local message = success_count == 1 and "Added 1 file to Claude context" or string.format("Added %d files to Claude context", success_count) if total_count > success_count then message = message .. string.format(" (%d failed)", total_count - success_count) end if total_count > success_count then if success_count > 0 then logger.warn(context, message) else logger.error(context, message) end elseif success_count > 0 then logger.info(context, message) else logger.debug(context, message) end end end return success_count, total_count end return M ================================================ FILE: lua/claudecode/integrations.lua ================================================ --- Tree integration module for ClaudeCode.nvim --- Handles detection and selection of files from nvim-tree, neo-tree, mini.files, and oil.nvim ---@module 'claudecode.integrations' local M = {} ---Get selected files from the current tree explorer ---@return table|nil files List of file paths, or nil if error ---@return string|nil error Error message if operation failed function M.get_selected_files_from_tree() local current_ft = vim.bo.filetype if current_ft == "NvimTree" then return M._get_nvim_tree_selection() elseif current_ft == "neo-tree" then return M._get_neotree_selection() elseif current_ft == "oil" then return M._get_oil_selection() elseif current_ft == "minifiles" then return M._get_mini_files_selection() else return nil, "Not in a supported tree buffer (current filetype: " .. current_ft .. ")" end end ---Get selected files from nvim-tree ---Supports both multi-selection (marks) and single file under cursor ---@return table files List of file paths ---@return string|nil error Error message if operation failed function M._get_nvim_tree_selection() local success, nvim_tree_api = pcall(require, "nvim-tree.api") if not success then return {}, "nvim-tree not available" end local files = {} local marks = nvim_tree_api.marks.list() if marks and #marks > 0 then for _, mark in ipairs(marks) do if mark.type == "file" and mark.absolute_path and mark.absolute_path ~= "" then -- Check if it's not a root-level file (basic protection) if not string.match(mark.absolute_path, "^/[^/]*$") then table.insert(files, mark.absolute_path) end end end if #files > 0 then return files, nil end end local node = nvim_tree_api.tree.get_node_under_cursor() if node then if node.type == "file" and node.absolute_path and node.absolute_path ~= "" then -- Check if it's not a root-level file (basic protection) if not string.match(node.absolute_path, "^/[^/]*$") then return { node.absolute_path }, nil else return {}, "Cannot add root-level file. Please select a file in a subdirectory." end elseif node.type == "directory" and node.absolute_path and node.absolute_path ~= "" then return { node.absolute_path }, nil end end return {}, "No file found under cursor" end ---Get selected files from neo-tree ---Uses neo-tree's own visual selection method when in visual mode ---@return table files List of file paths ---@return string|nil error Error message if operation failed function M._get_neotree_selection() local success, manager = pcall(require, "neo-tree.sources.manager") if not success then return {}, "neo-tree not available" end local state = manager.get_state("filesystem") if not state then return {}, "neo-tree filesystem state not available" end local files = {} -- Use neo-tree's own visual selection method (like their copy/paste feature) local mode = vim.fn.mode() if mode == "V" or mode == "v" or mode == "\22" then local current_win = vim.api.nvim_get_current_win() if state.winid and state.winid == current_win then -- Use neo-tree's exact method to get visual range (from their get_selected_nodes implementation) local start_pos = vim.fn.getpos("'<")[2] local end_pos = vim.fn.getpos("'>")[2] -- Fallback to current cursor and anchor if marks are not valid if start_pos == 0 or end_pos == 0 then local cursor_pos = vim.api.nvim_win_get_cursor(0)[1] local anchor_pos = vim.fn.getpos("v")[2] if anchor_pos > 0 then start_pos = math.min(cursor_pos, anchor_pos) end_pos = math.max(cursor_pos, anchor_pos) else start_pos = cursor_pos end_pos = cursor_pos end end if end_pos < start_pos then start_pos, end_pos = end_pos, start_pos end local selected_nodes = {} for line = start_pos, end_pos do local node = state.tree:get_node(line) if node then -- Add validation for node types before adding to selection if node.type and node.type ~= "message" then table.insert(selected_nodes, node) end end end for _, node in ipairs(selected_nodes) do -- Enhanced validation: check for file type and valid path if node.type == "file" and node.path and node.path ~= "" then -- Additional check: ensure it's not a root node (depth protection) local depth = (node.get_depth and node:get_depth()) and node:get_depth() or 0 if depth > 1 then table.insert(files, node.path) end end end if #files > 0 then return files, nil end end end if state.tree then local selection = nil if state.tree.get_selection then selection = state.tree:get_selection() end if (not selection or #selection == 0) and state.selected_nodes then selection = state.selected_nodes end if selection and #selection > 0 then for _, node in ipairs(selection) do if node.type == "file" and node.path then table.insert(files, node.path) end end if #files > 0 then return files, nil end end end if state.tree then local node = state.tree:get_node() if node then if node.type == "file" and node.path then return { node.path }, nil elseif node.type == "directory" and node.path then return { node.path }, nil end end end return {}, "No file found under cursor" end ---Get selected files from oil.nvim ---Supports both visual selection and single file under cursor ---@return table files List of file paths ---@return string|nil error Error message if operation failed function M._get_oil_selection() local success, oil = pcall(require, "oil") if not success then return {}, "oil.nvim not available" end local bufnr = vim.api.nvim_get_current_buf() --[[@as number]] local files = {} -- Check if we're in visual mode local mode = vim.fn.mode() if mode == "V" or mode == "v" or mode == "\22" then -- Visual mode: use the common visual range function local visual_commands = require("claudecode.visual_commands") local start_line, end_line = visual_commands.get_visual_range() -- Get current directory once local dir_ok, current_dir = pcall(oil.get_current_dir, bufnr) if not dir_ok or not current_dir then return {}, "Failed to get current directory" end -- Process each line in the visual selection for line = start_line, end_line do local entry_ok, entry = pcall(oil.get_entry_on_line, bufnr, line) if entry_ok and entry and entry.name then -- Skip parent directory entries if entry.name ~= ".." and entry.name ~= "." then local full_path = current_dir .. entry.name -- Handle various entry types if entry.type == "file" or entry.type == "link" then table.insert(files, full_path) elseif entry.type == "directory" then -- Ensure directory paths end with / table.insert(files, full_path:match("/$") and full_path or full_path .. "/") else -- For unknown types, return the path anyway table.insert(files, full_path) end end end end if #files > 0 then return files, nil end else -- Normal mode: get file under cursor with error handling local ok, entry = pcall(oil.get_cursor_entry) if not ok or not entry then return {}, "Failed to get cursor entry" end local dir_ok, current_dir = pcall(oil.get_current_dir, bufnr) if not dir_ok or not current_dir then return {}, "Failed to get current directory" end -- Process the entry if entry.name and entry.name ~= ".." and entry.name ~= "." then local full_path = current_dir .. entry.name -- Handle various entry types if entry.type == "file" or entry.type == "link" then return { full_path }, nil elseif entry.type == "directory" then -- Ensure directory paths end with / return { full_path:match("/$") and full_path or full_path .. "/" }, nil else -- For unknown types, return the path anyway return { full_path }, nil end end end return {}, "No file found under cursor" end -- Helper function to get mini.files selection using explicit range function M._get_mini_files_selection_with_range(start_line, end_line) local success, mini_files = pcall(require, "mini.files") if not success then return {}, "mini.files not available" end local files = {} local bufnr = vim.api.nvim_get_current_buf() -- Process each line in the range for line = start_line, end_line do local entry_ok, entry = pcall(mini_files.get_fs_entry, bufnr, line) if entry_ok and entry and entry.path and entry.path ~= "" then -- Extract real filesystem path from mini.files buffer path local real_path = entry.path -- Remove mini.files buffer protocol prefix if present if real_path:match("^minifiles://") then real_path = real_path:gsub("^minifiles://[^/]*/", "") end -- Validate that the path exists if vim.fn.filereadable(real_path) == 1 or vim.fn.isdirectory(real_path) == 1 then table.insert(files, real_path) end end end if #files > 0 then return files, nil else return {}, "No files found in range" end end ---Get selected files from mini.files ---Supports both visual selection and single file under cursor ---Reference: mini.files API MiniFiles.get_fs_entry() ---@return table files List of file paths ---@return string|nil error Error message if operation failed function M._get_mini_files_selection() local success, mini_files = pcall(require, "mini.files") if not success then return {}, "mini.files not available" end local bufnr = vim.api.nvim_get_current_buf() -- Normal mode: get file under cursor local entry_ok, entry = pcall(mini_files.get_fs_entry, bufnr) if not entry_ok or not entry then return {}, "Failed to get entry from mini.files" end if entry.path and entry.path ~= "" then -- Extract real filesystem path from mini.files buffer path local real_path = entry.path -- Remove mini.files buffer protocol prefix if present if real_path:match("^minifiles://") then real_path = real_path:gsub("^minifiles://[^/]*/", "") end -- Validate that the path exists if vim.fn.filereadable(real_path) == 1 or vim.fn.isdirectory(real_path) == 1 then return { real_path }, nil else return {}, "Invalid file or directory path: " .. real_path end end return {}, "No file found under cursor" end return M ================================================ FILE: lua/claudecode/lockfile.lua ================================================ ---@brief [[ --- Lock file management for Claude Code Neovim integration. --- This module handles creation, removal and updating of lock files --- which allow Claude Code CLI to discover the Neovim integration. ---@brief ]] ---@module 'claudecode.lockfile' local M = {} ---Path to the lock file directory ---@return string lock_dir The path to the lock file directory local function get_lock_dir() local claude_config_dir = os.getenv("CLAUDE_CONFIG_DIR") if claude_config_dir and claude_config_dir ~= "" then return vim.fn.expand(claude_config_dir .. "/ide") else return vim.fn.expand("~/.claude/ide") end end M.lock_dir = get_lock_dir() -- Track if random seed has been initialized local random_initialized = false ---Generate a random UUID for authentication ---@return string uuid A randomly generated UUID string local function generate_auth_token() -- Initialize random seed only once if not random_initialized then local seed = os.time() + vim.fn.getpid() -- Add more entropy if available if vim.loop and vim.loop.hrtime then seed = seed + (vim.loop.hrtime() % 1000000) end math.randomseed(seed) -- Call math.random a few times to "warm up" the generator for _ = 1, 10 do math.random() end random_initialized = true end -- Generate UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" local uuid = template:gsub("[xy]", function(c) local v = (c == "x") and math.random(0, 15) or math.random(8, 11) return string.format("%x", v) end) -- Validate generated UUID format if not uuid:match("^[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+$") then error("Generated invalid UUID format: " .. uuid) end if #uuid ~= 36 then error("Generated UUID has invalid length: " .. #uuid .. " (expected 36)") end return uuid end ---Generate a new authentication token ---@return string auth_token A newly generated authentication token function M.generate_auth_token() return generate_auth_token() end ---Create the lock file for a specified WebSocket port ---@param port number The port number for the WebSocket server ---@param auth_token? string Optional pre-generated auth token (generates new one if not provided) ---@return boolean success Whether the operation was successful ---@return string result_or_error The lock file path if successful, or error message if failed ---@return string? auth_token The authentication token if successful function M.create(port, auth_token) if not port or type(port) ~= "number" then return false, "Invalid port number" end if port < 1 or port > 65535 then return false, "Port number out of valid range (1-65535): " .. tostring(port) end local ok, err = pcall(function() return vim.fn.mkdir(M.lock_dir, "p") end) if not ok then return false, "Failed to create lock directory: " .. (err or "unknown error") end local lock_path = M.lock_dir .. "/" .. port .. ".lock" local workspace_folders = M.get_workspace_folders() if not auth_token then local auth_success, auth_result = pcall(generate_auth_token) if not auth_success then return false, "Failed to generate authentication token: " .. (auth_result or "unknown error") end auth_token = auth_result else -- Validate provided auth_token if type(auth_token) ~= "string" then return false, "Authentication token must be a string, got " .. type(auth_token) end if #auth_token < 10 then return false, "Authentication token too short (minimum 10 characters)" end if #auth_token > 500 then return false, "Authentication token too long (maximum 500 characters)" end end -- Prepare lock file content local lock_content = { pid = vim.fn.getpid(), workspaceFolders = workspace_folders, ideName = "Neovim", transport = "ws", authToken = auth_token, } local json local ok_json, json_err = pcall(function() json = vim.json.encode(lock_content) return json end) if not ok_json or not json then return false, "Failed to encode lock file content: " .. (json_err or "unknown error") end local file = io.open(lock_path, "w") if not file then return false, "Failed to create lock file: " .. lock_path end local write_ok, write_err = pcall(function() file:write(json) file:close() end) if not write_ok then pcall(function() file:close() end) return false, "Failed to write lock file: " .. (write_err or "unknown error") end return true, lock_path, auth_token end ---Remove the lock file for the given port ---@param port number The port number of the WebSocket server ---@return boolean success Whether the operation was successful ---@return string? error Error message if operation failed function M.remove(port) if not port or type(port) ~= "number" then return false, "Invalid port number" end local lock_path = M.lock_dir .. "/" .. port .. ".lock" if vim.fn.filereadable(lock_path) == 0 then return false, "Lock file does not exist: " .. lock_path end local ok, err = pcall(function() return os.remove(lock_path) end) if not ok then return false, "Failed to remove lock file: " .. (err or "unknown error") end return true end ---Update the lock file for the given port ---@param port number The port number of the WebSocket server ---@return boolean success Whether the operation was successful ---@return string result_or_error The lock file path if successful, or error message if failed ---@return string? auth_token The authentication token if successful function M.update(port) if not port or type(port) ~= "number" then return false, "Invalid port number" end local exists = vim.fn.filereadable(M.lock_dir .. "/" .. port .. ".lock") == 1 if exists then local remove_ok, remove_err = M.remove(port) if not remove_ok then return false, "Failed to update lock file: " .. remove_err end end return M.create(port) end ---Read the authentication token from a lock file ---@param port number The port number of the WebSocket server ---@return boolean success Whether the operation was successful ---@return string? auth_token The authentication token if successful, or nil if failed ---@return string? error Error message if operation failed function M.get_auth_token(port) if not port or type(port) ~= "number" then return false, nil, "Invalid port number" end local lock_path = M.lock_dir .. "/" .. port .. ".lock" if vim.fn.filereadable(lock_path) == 0 then return false, nil, "Lock file does not exist: " .. lock_path end local file = io.open(lock_path, "r") if not file then return false, nil, "Failed to open lock file: " .. lock_path end local content = file:read("*all") file:close() if not content or content == "" then return false, nil, "Lock file is empty: " .. lock_path end local ok, lock_data = pcall(vim.json.decode, content) if not ok or type(lock_data) ~= "table" then return false, nil, "Failed to parse lock file JSON: " .. lock_path end local auth_token = lock_data.authToken if not auth_token or type(auth_token) ~= "string" then return false, nil, "No valid auth token found in lock file" end return true, auth_token, nil end ---Get active LSP clients using available API ---@return table Array of LSP clients local function get_lsp_clients() if vim.lsp then if vim.lsp.get_clients then -- Neovim >= 0.11 return vim.lsp.get_clients() elseif vim.lsp.get_active_clients then -- Neovim 0.8-0.10 return vim.lsp.get_active_clients() end end return {} end ---Get workspace folders for the lock file ---@return table Array of workspace folder paths function M.get_workspace_folders() local folders = {} -- Add current working directory table.insert(folders, vim.fn.getcwd()) -- Get LSP workspace folders if available local clients = get_lsp_clients() for _, client in pairs(clients) do if client.config and client.config.workspace_folders then for _, ws in ipairs(client.config.workspace_folders) do -- Convert URI to path local path = ws.uri if path:sub(1, 7) == "file://" then path = path:sub(8) end -- Check if already in the list local exists = false for _, folder in ipairs(folders) do if folder == path then exists = true break end end if not exists then table.insert(folders, path) end end end end return folders end return M ================================================ FILE: lua/claudecode/logger.lua ================================================ ---@brief Centralized logger for Claude Code Neovim integration. -- Provides level-based logging. ---@module 'claudecode.logger' local M = {} M.levels = { ERROR = 1, WARN = 2, INFO = 3, DEBUG = 4, TRACE = 5, } local level_values = { error = M.levels.ERROR, warn = M.levels.WARN, info = M.levels.INFO, debug = M.levels.DEBUG, trace = M.levels.TRACE, } local current_log_level_value = M.levels.INFO ---Setup the logger module ---@param plugin_config ClaudeCode.Config The configuration table (e.g., from claudecode.init.state.config). function M.setup(plugin_config) local conf = plugin_config if conf and conf.log_level and level_values[conf.log_level] then current_log_level_value = level_values[conf.log_level] else vim.notify( "ClaudeCode Logger: Invalid or missing log_level in configuration (received: " .. tostring(conf and conf.log_level) .. "). Defaulting to INFO.", vim.log.levels.WARN ) current_log_level_value = M.levels.INFO end end local function log(level, component, message_parts) if level > current_log_level_value then return end local prefix = "[ClaudeCode]" if component then prefix = prefix .. " [" .. component .. "]" end local level_name = "UNKNOWN" for name, val in pairs(M.levels) do if val == level then level_name = name break end end prefix = prefix .. " [" .. level_name .. "]" local message = "" for i, part in ipairs(message_parts) do if i > 1 then message = message .. " " end if type(part) == "table" or type(part) == "boolean" then message = message .. vim.inspect(part) else message = message .. tostring(part) end end -- Wrap all vim.notify and nvim_echo calls in vim.schedule to avoid -- "nvim_echo must not be called in a fast event context" errors vim.schedule(function() if level == M.levels.ERROR then vim.notify(prefix .. " " .. message, vim.log.levels.ERROR, { title = "ClaudeCode Error" }) elseif level == M.levels.WARN then vim.notify(prefix .. " " .. message, vim.log.levels.WARN, { title = "ClaudeCode Warning" }) else -- For INFO, DEBUG, TRACE, use nvim_echo to avoid flooding notifications, -- to make them appear in :messages vim.api.nvim_echo({ { prefix .. " " .. message, "Normal" } }, true, {}) end end) end ---Error level logging ---@param component string|nil Optional component/module name. ---@param ... any Varargs representing parts of the message. function M.error(component, ...) if type(component) ~= "string" then log(M.levels.ERROR, nil, { component, ... }) else log(M.levels.ERROR, component, { ... }) end end ---Warn level logging ---@param component string|nil Optional component/module name. ---@param ... any Varargs representing parts of the message. function M.warn(component, ...) if type(component) ~= "string" then log(M.levels.WARN, nil, { component, ... }) else log(M.levels.WARN, component, { ... }) end end ---Info level logging ---@param component string|nil Optional component/module name. ---@param ... any Varargs representing parts of the message. function M.info(component, ...) if type(component) ~= "string" then log(M.levels.INFO, nil, { component, ... }) else log(M.levels.INFO, component, { ... }) end end ---Check if a specific log level is enabled ---@param level_name ClaudeCode.LogLevel The level name ("error", "warn", "info", "debug", "trace") ---@return boolean enabled Whether the level is enabled function M.is_level_enabled(level_name) local level_value = level_values[level_name] if not level_value then return false end return level_value <= current_log_level_value end ---Debug level logging ---@param component string|nil Optional component/module name. ---@param ... any Varargs representing parts of the message. function M.debug(component, ...) if type(component) ~= "string" then log(M.levels.DEBUG, nil, { component, ... }) else log(M.levels.DEBUG, component, { ... }) end end ---Trace level logging ---@param component string|nil Optional component/module name. ---@param ... any Varargs representing parts of the message. function M.trace(component, ...) if type(component) ~= "string" then log(M.levels.TRACE, nil, { component, ... }) else log(M.levels.TRACE, component, { ... }) end end return M ================================================ FILE: lua/claudecode/selection.lua ================================================ ---Manages selection tracking and communication with the Claude server. ---@module 'claudecode.selection' local M = {} local logger = require("claudecode.logger") local terminal = require("claudecode.terminal") M.state = { latest_selection = nil, tracking_enabled = false, debounce_timer = nil, debounce_ms = 100, last_active_visual_selection = nil, demotion_timer = nil, visual_demotion_delay_ms = 50, } ---Enables selection tracking. ---@param server table The server object to use for communication. ---@param visual_demotion_delay_ms number The delay for visual selection demotion. function M.enable(server, visual_demotion_delay_ms) if M.state.tracking_enabled then return end M.state.tracking_enabled = true M.server = server M.state.visual_demotion_delay_ms = visual_demotion_delay_ms M._create_autocommands() end ---Disables selection tracking. ---Clears autocommands, resets internal state, and stops any active debounce timers. function M.disable() if not M.state.tracking_enabled then return end M.state.tracking_enabled = false M._clear_autocommands() M.state.latest_selection = nil M.server = nil if M.state.debounce_timer then vim.loop.timer_stop(M.state.debounce_timer) M.state.debounce_timer = nil end end ---Creates autocommands for tracking selections. ---Sets up listeners for CursorMoved, CursorMovedI, ModeChanged, and TextChanged events. ---@local function M._create_autocommands() local group = vim.api.nvim_create_augroup("ClaudeCodeSelection", { clear = true }) vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { group = group, callback = function() M.on_cursor_moved() end, }) vim.api.nvim_create_autocmd("ModeChanged", { group = group, callback = function() M.on_mode_changed() end, }) vim.api.nvim_create_autocmd("TextChanged", { group = group, callback = function() M.on_text_changed() end, }) end ---Clears the autocommands related to selection tracking. ---@local function M._clear_autocommands() vim.api.nvim_clear_autocmds({ group = "ClaudeCodeSelection" }) end ---Handles cursor movement events. ---Triggers a debounced update of the selection. function M.on_cursor_moved() M.debounce_update() end ---Handles mode change events. ---Triggers an immediate update of the selection. function M.on_mode_changed() M.debounce_update() end ---Handles text change events. ---Triggers a debounced update of the selection. function M.on_text_changed() M.debounce_update() end ---Debounces selection updates. ---Ensures that `update_selection` is not called too frequently by deferring ---its execution. function M.debounce_update() if M.state.debounce_timer then vim.loop.timer_stop(M.state.debounce_timer) end M.state.debounce_timer = vim.defer_fn(function() M.update_selection() M.state.debounce_timer = nil end, M.state.debounce_ms) end ---Updates the current selection state. ---Determines the current selection based on the editor mode (visual or normal) ---and sends an update to the server if the selection has changed. function M.update_selection() if not M.state.tracking_enabled then return end local current_buf = vim.api.nvim_get_current_buf() local buf_name = vim.api.nvim_buf_get_name(current_buf) -- If the buffer name starts with "✻ [Claude Code]", do not update selection if buf_name and string.sub(buf_name, 1, string.len("✻ [Claude Code]")) == "✻ [Claude Code]" then -- Optionally, cancel demotion timer like for the terminal if M.state.demotion_timer then M.state.demotion_timer:stop() M.state.demotion_timer:close() M.state.demotion_timer = nil end return end -- If the current buffer is the Claude terminal, do not update selection if terminal then local claude_term_bufnr = terminal.get_active_terminal_bufnr() if claude_term_bufnr and current_buf == claude_term_bufnr then -- Cancel any pending demotion if we switch to the Claude terminal if M.state.demotion_timer then M.state.demotion_timer:stop() M.state.demotion_timer:close() M.state.demotion_timer = nil end return end end local current_mode_info = vim.api.nvim_get_mode() local current_mode = current_mode_info.mode local current_selection if current_mode == "v" or current_mode == "V" or current_mode == "\022" then -- If a new visual selection is made, cancel any pending demotion if M.state.demotion_timer then M.state.demotion_timer:stop() M.state.demotion_timer:close() M.state.demotion_timer = nil end current_selection = M.get_visual_selection() if current_selection then M.state.last_active_visual_selection = { bufnr = current_buf, selection_data = vim.deepcopy(current_selection), -- Store a copy timestamp = vim.loop.now(), } else -- No valid visual selection (e.g., get_visual_selection returned nil) -- Clear last_active_visual if it was for this buffer if M.state.last_active_visual_selection and M.state.last_active_visual_selection.bufnr == current_buf then M.state.last_active_visual_selection = nil end end else local last_visual = M.state.last_active_visual_selection if M.state.demotion_timer then -- A demotion is already pending. For this specific update_selection call (e.g. cursor moved), -- current_selection reflects the immediate cursor position. -- M.state.latest_selection (the one that might be sent) is still the visual one until timer resolves. current_selection = M.get_cursor_position() elseif last_visual and last_visual.bufnr == current_buf and last_visual.selection_data and not last_visual.selection_data.selection.isEmpty then -- We just exited visual mode in this buffer, and no demotion timer is running for it. -- Keep M.state.latest_selection as is (it's the visual one from the previous update). -- The 'current_selection' for comparison should also be this visual one. current_selection = M.state.latest_selection if M.state.demotion_timer then -- Should not happen due to elseif, but as safeguard M.state.demotion_timer:stop() M.state.demotion_timer:close() end M.state.demotion_timer = vim.loop.new_timer() M.state.demotion_timer:start( M.state.visual_demotion_delay_ms, 0, -- 0 repeat = one-shot vim.schedule_wrap(function() if M.state.demotion_timer then -- Check if it wasn't cancelled right before firing M.state.demotion_timer:stop() M.state.demotion_timer:close() M.state.demotion_timer = nil end M.handle_selection_demotion(current_buf) -- Pass buffer at time of scheduling end) ) else -- Genuinely in normal mode, no recent visual exit, no pending demotion. current_selection = M.get_cursor_position() if last_visual and last_visual.bufnr == current_buf then M.state.last_active_visual_selection = nil -- Clear it as it's no longer relevant for demotion end end end -- If current_selection could not be determined (e.g. get_visual_selection was nil and no other path set it) -- default to cursor position to avoid errors. if not current_selection then current_selection = M.get_cursor_position() end local changed = M.has_selection_changed(current_selection) if changed then M.state.latest_selection = current_selection if M.server then M.send_selection_update(current_selection) end end end ---Handles the demotion of a visual selection after a delay. ---Called by the demotion_timer. ---@param original_bufnr_when_scheduled number The buffer number that was active when demotion was scheduled. function M.handle_selection_demotion(original_bufnr_when_scheduled) -- Timer object is already stopped and cleared by its own callback wrapper or cancellation points. -- M.state.demotion_timer should be nil here if it fired normally or was cancelled. local current_buf = vim.api.nvim_get_current_buf() local claude_term_bufnr = terminal.get_active_terminal_bufnr() -- Condition 1: Switched to Claude Terminal if claude_term_bufnr and current_buf == claude_term_bufnr then -- Visual selection is preserved (M.state.latest_selection is still the visual one). -- The "pending" status of last_active_visual_selection is resolved. if M.state.last_active_visual_selection and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled then M.state.last_active_visual_selection = nil end return end local current_mode_info = vim.api.nvim_get_mode() -- Condition 2: Back in Visual Mode in the Original Buffer if current_buf == original_bufnr_when_scheduled and (current_mode_info.mode == "v" or current_mode_info.mode == "V" or current_mode_info.mode == "\022") then -- A new visual selection will take precedence. M.state.latest_selection will be updated by main flow. if M.state.last_active_visual_selection and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled then M.state.last_active_visual_selection = nil end return end -- Condition 3: Still in Original Buffer & Not Visual & Not Claude Term -> Demote if current_buf == original_bufnr_when_scheduled then local new_sel_for_demotion = M.get_cursor_position() -- Check if this new cursor position is actually different from the (visual) latest_selection if M.has_selection_changed(new_sel_for_demotion) then M.state.latest_selection = new_sel_for_demotion if M.server then M.send_selection_update(M.state.latest_selection) end end -- No change detected in selection end -- User switched to different buffer -- Always clear last_active_visual_selection for the original buffer as its pending demotion is resolved. if M.state.last_active_visual_selection and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled then M.state.last_active_visual_selection = nil end end ---Validates if we're in a valid visual selection mode ---@return boolean valid, string? error - true if valid, false and error message if not local function validate_visual_mode() local current_nvim_mode = vim.api.nvim_get_mode().mode local fixed_anchor_pos_raw = vim.fn.getpos("v") if not (current_nvim_mode == "v" or current_nvim_mode == "V" or current_nvim_mode == "\22") then return false, "not in visual mode" end if fixed_anchor_pos_raw[2] == 0 then return false, "no visual selection mark" end return true, nil end ---Determines the effective visual mode character ---@return string|nil - the visual mode character or nil if invalid local function get_effective_visual_mode() local current_nvim_mode = vim.api.nvim_get_mode().mode local visual_fn_mode_char = vim.fn.visualmode() if visual_fn_mode_char and visual_fn_mode_char ~= "" then return visual_fn_mode_char end -- Fallback to current mode if current_nvim_mode == "V" then return "V" elseif current_nvim_mode == "v" then return "v" elseif current_nvim_mode == "\22" then -- Ctrl-V, blockwise return "\22" end return nil end ---Gets the start and end coordinates of the visual selection ---@return table, table - start_coords and end_coords with lnum and col fields local function get_selection_coordinates() local fixed_anchor_pos_raw = vim.fn.getpos("v") local current_cursor_nvim = vim.api.nvim_win_get_cursor(0) -- Convert to 1-indexed line and 1-indexed column for consistency local p1 = { lnum = fixed_anchor_pos_raw[2], col = fixed_anchor_pos_raw[3] } local p2 = { lnum = current_cursor_nvim[1], col = current_cursor_nvim[2] + 1 } -- Determine chronological start/end based on line, then column if p1.lnum < p2.lnum or (p1.lnum == p2.lnum and p1.col <= p2.col) then return p1, p2 else return p2, p1 end end ---Extracts text for linewise visual selection ---@param lines_content table - array of line strings ---@param start_coords table - start coordinates ---@return string text - the extracted text local function extract_linewise_text(lines_content, start_coords) start_coords.col = 1 -- Linewise selection effectively starts at column 1 return table.concat(lines_content, "\n") end ---Extracts text for characterwise visual selection ---@param lines_content table - array of line strings ---@param start_coords table - start coordinates ---@param end_coords table - end coordinates ---@return string|nil text - the extracted text or nil if invalid local function extract_characterwise_text(lines_content, start_coords, end_coords) if start_coords.lnum == end_coords.lnum then if not lines_content[1] then return nil end return string.sub(lines_content[1], start_coords.col, end_coords.col) else if not lines_content[1] or not lines_content[#lines_content] then return nil end local text_parts = {} table.insert(text_parts, string.sub(lines_content[1], start_coords.col)) for i = 2, #lines_content - 1 do table.insert(text_parts, lines_content[i]) end table.insert(text_parts, string.sub(lines_content[#lines_content], 1, end_coords.col)) return table.concat(text_parts, "\n") end end ---Calculates LSP-compatible position coordinates ---@param start_coords table - start coordinates ---@param end_coords table - end coordinates ---@param visual_mode string - the visual mode character ---@param lines_content table - array of line strings ---@return table position - LSP position object with start and end fields local function calculate_lsp_positions(start_coords, end_coords, visual_mode, lines_content) local lsp_start_line = start_coords.lnum - 1 local lsp_end_line = end_coords.lnum - 1 local lsp_start_char, lsp_end_char if visual_mode == "V" then lsp_start_char = 0 -- Linewise selection always starts at character 0 -- For linewise, LSP end char is length of the last selected line if #lines_content > 0 and lines_content[#lines_content] then lsp_end_char = #lines_content[#lines_content] else lsp_end_char = 0 end else lsp_start_char = start_coords.col - 1 lsp_end_char = end_coords.col end return { start = { line = lsp_start_line, character = lsp_start_char }, ["end"] = { line = lsp_end_line, character = lsp_end_char }, } end ---Gets the current visual selection details. ---@return table|nil selection A table containing selection text, file path, URL, and ---start/end positions, or nil if no visual selection exists. function M.get_visual_selection() local valid = validate_visual_mode() if not valid then return nil end local visual_mode = get_effective_visual_mode() if not visual_mode then return nil end local start_coords, end_coords = get_selection_coordinates() local current_buf = vim.api.nvim_get_current_buf() local file_path = vim.api.nvim_buf_get_name(current_buf) local lines_content = vim.api.nvim_buf_get_lines( current_buf, start_coords.lnum - 1, -- Convert to 0-indexed end_coords.lnum, -- nvim_buf_get_lines end is exclusive false ) if #lines_content == 0 then return nil end local final_text if visual_mode == "V" then final_text = extract_linewise_text(lines_content, start_coords) elseif visual_mode == "v" or visual_mode == "\22" then final_text = extract_characterwise_text(lines_content, start_coords, end_coords) if not final_text then return nil end else return nil end local lsp_positions = calculate_lsp_positions(start_coords, end_coords, visual_mode, lines_content) return { text = final_text or "", filePath = file_path, fileUrl = "file://" .. file_path, selection = { start = lsp_positions.start, ["end"] = lsp_positions["end"], isEmpty = (not final_text or #final_text == 0), }, } end ---Gets the current cursor position when no visual selection is active. ---@return table A table containing an empty text, file path, URL, and cursor ---position as start/end, with isEmpty set to true. function M.get_cursor_position() local cursor_pos = vim.api.nvim_win_get_cursor(0) local current_buf = vim.api.nvim_get_current_buf() local file_path = vim.api.nvim_buf_get_name(current_buf) return { text = "", filePath = file_path, fileUrl = "file://" .. file_path, selection = { start = { line = cursor_pos[1] - 1, character = cursor_pos[2] }, ["end"] = { line = cursor_pos[1] - 1, character = cursor_pos[2] }, isEmpty = true, }, } end ---Checks if the selection has changed compared to the latest stored selection. ---@param new_selection table|nil The new selection object to compare. ---@return boolean changed true if the selection has changed, false otherwise. function M.has_selection_changed(new_selection) local old_selection = M.state.latest_selection if not new_selection then return old_selection ~= nil end if not old_selection then return true end if old_selection.filePath ~= new_selection.filePath then return true end if old_selection.text ~= new_selection.text then return true end if old_selection.selection.start.line ~= new_selection.selection.start.line or old_selection.selection.start.character ~= new_selection.selection.start.character or old_selection.selection["end"].line ~= new_selection.selection["end"].line or old_selection.selection["end"].character ~= new_selection.selection["end"].character then return true end return false end ---Sends the selection update to the Claude server. ---@param selection table The selection object to send. function M.send_selection_update(selection) M.server.broadcast("selection_changed", selection) end ---Gets the latest recorded selection. ---@return table|nil The latest selection object, or nil if none recorded. function M.get_latest_selection() return M.state.latest_selection end ---Sends the current selection to Claude. ---This function is typically invoked by a user command. It forces an immediate ---update and sends the latest selection. function M.send_current_selection() if not M.state.tracking_enabled or not M.server then logger.error("selection", "Claude Code is not running") return end M.update_selection() local selection = M.state.latest_selection if not selection then logger.error("selection", "No selection available") return end M.send_selection_update(selection) vim.api.nvim_echo({ { "Selection sent to Claude", "Normal" } }, false, {}) end ---Gets selection from range marks (e.g., when using :'<,'> commands) ---@param line1 number The start line (1-indexed) ---@param line2 number The end line (1-indexed) ---@return table|nil A table containing selection text, file path, URL, and ---start/end positions, or nil if invalid range function M.get_range_selection(line1, line2) if not line1 or not line2 or line1 < 1 or line2 < 1 or line1 > line2 then return nil end local current_buf = vim.api.nvim_get_current_buf() local file_path = vim.api.nvim_buf_get_name(current_buf) -- Get the total number of lines in the buffer local total_lines = vim.api.nvim_buf_line_count(current_buf) -- Ensure line2 doesn't exceed buffer bounds if line2 > total_lines then line2 = total_lines end local lines_content = vim.api.nvim_buf_get_lines( current_buf, line1 - 1, -- Convert to 0-indexed line2, -- nvim_buf_get_lines end is exclusive false ) if #lines_content == 0 then return nil end local final_text = table.concat(lines_content, "\n") -- For range selections, we treat them as linewise local lsp_start_line = line1 - 1 -- Convert to 0-indexed local lsp_end_line = line2 - 1 local lsp_start_char = 0 local lsp_end_char = #lines_content[#lines_content] return { text = final_text or "", filePath = file_path, fileUrl = "file://" .. file_path, selection = { start = { line = lsp_start_line, character = lsp_start_char }, ["end"] = { line = lsp_end_line, character = lsp_end_char }, isEmpty = (not final_text or #final_text == 0), }, } end ---Sends an at_mentioned notification for the current visual selection. ---@param line1 number|nil Optional start line for range-based selection ---@param line2 number|nil Optional end line for range-based selection function M.send_at_mention_for_visual_selection(line1, line2) if not M.state.tracking_enabled then logger.error("selection", "Selection tracking is not enabled.") return false end -- Check if Claude Code integration is running (server may or may not have clients) local claudecode_main = require("claudecode") if not claudecode_main.state.server then logger.error("selection", "Claude Code integration is not running.") return false end local sel_to_send -- If range parameters are provided, use them (for :'<,'> commands) if line1 and line2 then sel_to_send = M.get_range_selection(line1, line2) if not sel_to_send or sel_to_send.selection.isEmpty then logger.warn("selection", "Invalid range selection to send as at-mention.") return false end else -- Use existing logic for visual mode or tracked selection sel_to_send = M.state.latest_selection if not sel_to_send or sel_to_send.selection.isEmpty then -- Fallback: try to get current visual selection directly. -- This helps if latest_selection was demoted or command was too fast. local current_visual = M.get_visual_selection() if current_visual and not current_visual.selection.isEmpty then sel_to_send = current_visual else logger.warn("selection", "No visual selection to send as at-mention.") return false end end end -- Sanity check: ensure the selection is for the current buffer local current_buf_name = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()) if sel_to_send.filePath ~= current_buf_name then logger.warn( "selection", "Tracked selection is for '" .. sel_to_send.filePath .. "', but current buffer is '" .. current_buf_name .. "'. Not sending." ) return false end -- Use connection-aware broadcasting from main module local file_path = sel_to_send.filePath local start_line = sel_to_send.selection.start.line -- Already 0-indexed from selection module local end_line = sel_to_send.selection["end"].line -- Already 0-indexed local success, error_msg = claudecode_main.send_at_mention(file_path, start_line, end_line, "ClaudeCodeSend") if success then logger.debug("selection", "Visual selection sent as at-mention.") return true else logger.error("selection", "Failed to send at-mention: " .. (error_msg or "unknown error")) return false end end return M ================================================ FILE: lua/claudecode/terminal.lua ================================================ --- Module to manage a dedicated vertical split terminal for Claude Code. --- Supports Snacks.nvim or a native Neovim terminal fallback. --- @module 'claudecode.terminal' --- @class TerminalProvider --- @field setup fun(config: TerminalConfig) --- @field open fun(cmd_string: string, env_table: table, config: TerminalConfig, focus: boolean?) --- @field close fun() --- @field toggle fun(cmd_string: string, env_table: table, effective_config: TerminalConfig) --- @field simple_toggle fun(cmd_string: string, env_table: table, effective_config: TerminalConfig) --- @field focus_toggle fun(cmd_string: string, env_table: table, effective_config: TerminalConfig) --- @field get_active_bufnr fun(): number? --- @field is_available fun(): boolean --- @field ensure_visible? function --- @field _get_terminal_for_test fun(): table? --- @class TerminalConfig --- @field split_side "left"|"right" --- @field split_width_percentage number --- @field provider "auto"|"snacks"|"native"|TerminalProvider --- @field show_native_term_exit_tip boolean --- @field terminal_cmd string|nil --- @field auto_close boolean --- @field env table --- @field snacks_win_opts table local M = {} local claudecode_server_module = require("claudecode.server.init") --- @type TerminalConfig local defaults = { split_side = "right", split_width_percentage = 0.30, provider = "auto", show_native_term_exit_tip = true, terminal_cmd = nil, auto_close = true, env = {}, snacks_win_opts = {}, } M.defaults = defaults -- Lazy load providers local providers = {} ---Loads a terminal provider module ---@param provider_name string The name of the provider to load ---@return TerminalProvider? provider The provider module, or nil if loading failed local function load_provider(provider_name) if not providers[provider_name] then local ok, provider = pcall(require, "claudecode.terminal." .. provider_name) if ok then providers[provider_name] = provider else return nil end end return providers[provider_name] end ---Validates and enhances a custom table provider with smart defaults ---@param provider TerminalProvider The custom provider table to validate ---@return TerminalProvider? provider The enhanced provider, or nil if invalid ---@return string? error Error message if validation failed local function validate_and_enhance_provider(provider) if type(provider) ~= "table" then return nil, "Custom provider must be a table" end -- Required functions that must be implemented local required_functions = { "setup", "open", "close", "simple_toggle", "focus_toggle", "get_active_bufnr", "is_available", } -- Validate all required functions exist and are callable for _, func_name in ipairs(required_functions) do local func = provider[func_name] if not func then return nil, "Custom provider missing required function: " .. func_name end -- Check if it's callable (function or table with __call metamethod) local is_callable = type(func) == "function" or (type(func) == "table" and getmetatable(func) and getmetatable(func).__call) if not is_callable then return nil, "Custom provider field '" .. func_name .. "' must be callable, got: " .. type(func) end end -- Create enhanced provider with defaults for optional functions -- Note: Don't deep copy to preserve spy functions in tests local enhanced_provider = provider -- Add default toggle function if not provided (calls simple_toggle for backward compatibility) if not enhanced_provider.toggle then enhanced_provider.toggle = function(cmd_string, env_table, effective_config) return enhanced_provider.simple_toggle(cmd_string, env_table, effective_config) end end -- Add default test function if not provided if not enhanced_provider._get_terminal_for_test then enhanced_provider._get_terminal_for_test = function() return nil end end return enhanced_provider, nil end ---Gets the effective terminal provider, guaranteed to return a valid provider ---Falls back to native provider if configured provider is unavailable ---@return TerminalProvider provider The terminal provider module (never nil) local function get_provider() local logger = require("claudecode.logger") -- Handle custom table provider if type(defaults.provider) == "table" then local custom_provider = defaults.provider --[[@as TerminalProvider]] local enhanced_provider, error_msg = validate_and_enhance_provider(custom_provider) if enhanced_provider then -- Check if custom provider is available local is_available_ok, is_available = pcall(enhanced_provider.is_available) if is_available_ok and is_available then logger.debug("terminal", "Using custom table provider") return enhanced_provider else local availability_msg = is_available_ok and "provider reports not available" or "error checking availability" logger.warn( "terminal", "Custom table provider configured but " .. availability_msg .. ". Falling back to 'native'." ) end else logger.warn("terminal", "Invalid custom table provider: " .. error_msg .. ". Falling back to 'native'.") end -- Fall through to native provider elseif defaults.provider == "auto" then -- Try snacks first, then fallback to native silently local snacks_provider = load_provider("snacks") if snacks_provider and snacks_provider.is_available() then return snacks_provider end -- Fall through to native provider elseif defaults.provider == "snacks" then local snacks_provider = load_provider("snacks") if snacks_provider and snacks_provider.is_available() then return snacks_provider else logger.warn("terminal", "'snacks' provider configured, but Snacks.nvim not available. Falling back to 'native'.") end elseif defaults.provider == "native" then -- noop, will use native provider as default below logger.debug("terminal", "Using native terminal provider") elseif type(defaults.provider) == "string" then logger.warn( "terminal", "Invalid provider configured: " .. tostring(defaults.provider) .. ". Defaulting to 'native'." ) else logger.warn( "terminal", "Invalid provider type: " .. type(defaults.provider) .. ". Must be string or table. Defaulting to 'native'." ) end local native_provider = load_provider("native") if not native_provider then error("ClaudeCode: Critical error - native terminal provider failed to load") end return native_provider end ---Builds the effective terminal configuration by merging defaults with overrides ---@param opts_override table? Optional overrides for terminal appearance ---@return table config The effective terminal configuration local function build_config(opts_override) local effective_config = vim.deepcopy(defaults) if type(opts_override) == "table" then local validators = { split_side = function(val) return val == "left" or val == "right" end, split_width_percentage = function(val) return type(val) == "number" and val > 0 and val < 1 end, snacks_win_opts = function(val) return type(val) == "table" end, } for key, val in pairs(opts_override) do if effective_config[key] ~= nil and validators[key] and validators[key](val) then effective_config[key] = val end end end return { split_side = effective_config.split_side, split_width_percentage = effective_config.split_width_percentage, auto_close = effective_config.auto_close, snacks_win_opts = effective_config.snacks_win_opts, } end ---Checks if a terminal buffer is currently visible in any window ---@param bufnr number? The buffer number to check ---@return boolean True if the buffer is visible in any window, false otherwise local function is_terminal_visible(bufnr) if not bufnr then return false end local bufinfo = vim.fn.getbufinfo(bufnr) return bufinfo and #bufinfo > 0 and #bufinfo[1].windows > 0 end ---Gets the claude command string and necessary environment variables ---@param cmd_args string? Optional arguments to append to the command ---@return string cmd_string The command string ---@return table env_table The environment variables table local function get_claude_command_and_env(cmd_args) -- Inline get_claude_command logic local cmd_from_config = defaults.terminal_cmd local base_cmd if not cmd_from_config or cmd_from_config == "" then base_cmd = "claude" -- Default if not configured else base_cmd = cmd_from_config end local cmd_string if cmd_args and cmd_args ~= "" then cmd_string = base_cmd .. " " .. cmd_args else cmd_string = base_cmd end local sse_port_value = claudecode_server_module.state.port local env_table = { ENABLE_IDE_INTEGRATION = "true", FORCE_CODE_TERMINAL = "true", } if sse_port_value then env_table["CLAUDE_CODE_SSE_PORT"] = tostring(sse_port_value) end -- Merge custom environment variables from config for key, value in pairs(defaults.env) do env_table[key] = value end return cmd_string, env_table end ---Common helper to open terminal without focus if not already visible ---@param opts_override table? Optional config overrides ---@param cmd_args string? Optional command arguments ---@return boolean visible True if terminal was opened or already visible local function ensure_terminal_visible_no_focus(opts_override, cmd_args) local provider = get_provider() -- Check if provider has an ensure_visible method if provider.ensure_visible then provider.ensure_visible() return true end local active_bufnr = provider.get_active_bufnr() if is_terminal_visible(active_bufnr) then -- Terminal is already visible, do nothing return true end -- Terminal is not visible, open it without focus local effective_config = build_config(opts_override) local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) provider.open(cmd_string, claude_env_table, effective_config, false) -- false = don't focus return true end ---Configures the terminal module. ---Merges user-provided terminal configuration with defaults and sets the terminal command. ---@param user_term_config TerminalConfig? Configuration options for the terminal. ---@param p_terminal_cmd string? The command to run in the terminal (from main config). ---@param p_env table? Custom environment variables to pass to the terminal (from main config). function M.setup(user_term_config, p_terminal_cmd, p_env) if user_term_config == nil then -- Allow nil, default to empty table silently user_term_config = {} elseif type(user_term_config) ~= "table" then -- Warn if it's not nil AND not a table vim.notify("claudecode.terminal.setup expects a table or nil for user_term_config", vim.log.levels.WARN) user_term_config = {} end if p_terminal_cmd == nil or type(p_terminal_cmd) == "string" then defaults.terminal_cmd = p_terminal_cmd else vim.notify( "claudecode.terminal.setup: Invalid terminal_cmd provided: " .. tostring(p_terminal_cmd) .. ". Using default.", vim.log.levels.WARN ) defaults.terminal_cmd = nil -- Fallback to default behavior end if p_env == nil or type(p_env) == "table" then defaults.env = p_env or {} else vim.notify( "claudecode.terminal.setup: Invalid env provided: " .. tostring(p_env) .. ". Using empty table.", vim.log.levels.WARN ) defaults.env = {} end for k, v in pairs(user_term_config) do if defaults[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above if k == "split_side" and (v == "left" or v == "right") then defaults[k] = v elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then defaults[k] = v elseif k == "provider" and (v == "snacks" or v == "native" or v == "auto" or type(v) == "table") then defaults[k] = v elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then defaults[k] = v elseif k == "auto_close" and type(v) == "boolean" then defaults[k] = v elseif k == "snacks_win_opts" and type(v) == "table" then defaults[k] = v else vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN) end elseif k ~= "terminal_cmd" then -- Avoid warning for terminal_cmd if passed in user_term_config vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN) end end -- Setup providers with config get_provider().setup(defaults) end ---Opens or focuses the Claude terminal. ---@param opts_override table? Overrides for terminal appearance (split_side, split_width_percentage). ---@param cmd_args string? Arguments to append to the claude command. function M.open(opts_override, cmd_args) local effective_config = build_config(opts_override) local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) get_provider().open(cmd_string, claude_env_table, effective_config) end ---Closes the managed Claude terminal if it's open and valid. function M.close() get_provider().close() end ---Simple toggle: always show/hide the Claude terminal regardless of focus. ---@param opts_override table? Overrides for terminal appearance (split_side, split_width_percentage). ---@param cmd_args string? Arguments to append to the claude command. function M.simple_toggle(opts_override, cmd_args) local effective_config = build_config(opts_override) local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) get_provider().simple_toggle(cmd_string, claude_env_table, effective_config) end ---Smart focus toggle: switches to terminal if not focused, hides if currently focused. ---@param opts_override table (optional) Overrides for terminal appearance (split_side, split_width_percentage). ---@param cmd_args string|nil (optional) Arguments to append to the claude command. function M.focus_toggle(opts_override, cmd_args) local effective_config = build_config(opts_override) local cmd_string, claude_env_table = get_claude_command_and_env(cmd_args) get_provider().focus_toggle(cmd_string, claude_env_table, effective_config) end ---Toggle open terminal without focus if not already visible, otherwise do nothing. ---@param opts_override table? Overrides for terminal appearance (split_side, split_width_percentage). ---@param cmd_args string? Arguments to append to the claude command. function M.toggle_open_no_focus(opts_override, cmd_args) ensure_terminal_visible_no_focus(opts_override, cmd_args) end ---Ensures terminal is visible without changing focus. Creates if necessary, shows if hidden. ---@param opts_override table? Overrides for terminal appearance (split_side, split_width_percentage). ---@param cmd_args string? Arguments to append to the claude command. function M.ensure_visible(opts_override, cmd_args) ensure_terminal_visible_no_focus(opts_override, cmd_args) end ---Toggles the Claude terminal open or closed (legacy function - use simple_toggle or focus_toggle). ---@param opts_override table? Overrides for terminal appearance (split_side, split_width_percentage). ---@param cmd_args string? Arguments to append to the claude command. function M.toggle(opts_override, cmd_args) -- Default to simple toggle for backward compatibility M.simple_toggle(opts_override, cmd_args) end ---Gets the buffer number of the currently active Claude Code terminal. ---This checks both Snacks and native fallback terminals. ---@return number|nil The buffer number if an active terminal is found, otherwise nil. function M.get_active_terminal_bufnr() return get_provider().get_active_bufnr() end ---Gets the managed terminal instance for testing purposes. -- NOTE: This function is intended for use in tests to inspect internal state. -- The underscore prefix indicates it's not part of the public API for regular use. ---@return table|nil terminal The managed terminal instance, or nil. function M._get_managed_terminal_for_test() local provider = get_provider() if provider and provider._get_terminal_for_test then return provider._get_terminal_for_test() end return nil end return M ================================================ FILE: lua/claudecode/utils.lua ================================================ ---Shared utility functions for claudecode.nvim ---@module 'claudecode.utils' local M = {} ---Normalizes focus parameter to default to true for backward compatibility ---@param focus boolean? The focus parameter ---@return boolean valid Whether the focus parameter is valid function M.normalize_focus(focus) if focus == nil then return true else return focus end end return M ================================================ FILE: lua/claudecode/visual_commands.lua ================================================ ---Visual command handling module for ClaudeCode.nvim ---Implements neo-tree-style visual mode exit and command processing ---@module 'claudecode.visual_commands' local M = {} ---Get current vim mode with fallback for test environments ---@param full_mode? boolean Whether to get full mode info (passed to vim.fn.mode) ---@return string current_mode The current vim mode local function get_current_mode(full_mode) local current_mode = "n" -- Default fallback pcall(function() if vim.api and vim.api.nvim_get_mode then current_mode = vim.api.nvim_get_mode().mode else current_mode = vim.fn.mode(full_mode) end end) return current_mode end ---ESC key constant matching neo-tree's implementation local ESC_KEY local success = pcall(function() ESC_KEY = vim.api.nvim_replace_termcodes("", true, false, true) end) if not success then ESC_KEY = "\27" end ---Exit visual mode properly and schedule command execution ---@param callback function The function to call after exiting visual mode ---@param ... any Arguments to pass to the callback function M.exit_visual_and_schedule(callback, ...) local args = { ... } -- Capture visual selection data BEFORE exiting visual mode local visual_data = M.capture_visual_selection_data() pcall(function() vim.api.nvim_feedkeys(ESC_KEY, "i", true) end) -- Schedule execution until after mode change (neo-tree pattern) local schedule_fn = vim.schedule or function(fn) fn() end -- Fallback for test environments schedule_fn(function() -- Pass the captured visual data as the first argument callback(visual_data, unpack(args)) end) end ---Validate that we're currently in a visual mode ---@return boolean valid true if in visual mode, false otherwise ---@return string? error error message if not in visual mode function M.validate_visual_mode() local current_mode = get_current_mode(true) local is_visual = current_mode == "v" or current_mode == "V" or current_mode == "\022" -- Additional debugging: check visual marks and cursor position if is_visual then pcall(function() vim.api.nvim_win_get_cursor(0) vim.fn.getpos("'<") vim.fn.getpos("'>") vim.fn.getpos("v") end) end if not is_visual then return false, "Not in visual mode (current mode: " .. current_mode .. ")" end return true, nil end ---Get visual selection range using vim marks or current cursor position ---@return number start_line, number end_line (1-indexed) function M.get_visual_range() local start_pos, end_pos = 1, 1 -- Default fallback -- Use pcall to handle test environments local range_success = pcall(function() -- Check if we're currently in visual mode local current_mode = get_current_mode(true) local is_visual = current_mode == "v" or current_mode == "V" or current_mode == "\022" if is_visual then -- In visual mode, ALWAYS use cursor + anchor (marks are stale until exit) local cursor_pos = vim.api.nvim_win_get_cursor(0)[1] local anchor_pos = vim.fn.getpos("v")[2] if anchor_pos > 0 then start_pos = math.min(cursor_pos, anchor_pos) end_pos = math.max(cursor_pos, anchor_pos) else -- Fallback: just use current cursor position start_pos = cursor_pos end_pos = cursor_pos end else -- Not in visual mode, try to use the marks (they should be valid now) local mark_start = vim.fn.getpos("'<")[2] local mark_end = vim.fn.getpos("'>")[2] if mark_start > 0 and mark_end > 0 then start_pos = mark_start end_pos = mark_end else -- No valid marks, use cursor position local cursor_pos = vim.api.nvim_win_get_cursor(0)[1] start_pos = cursor_pos end_pos = cursor_pos end end end) if not range_success then return 1, 1 end if end_pos < start_pos then start_pos, end_pos = end_pos, start_pos end -- Ensure we have valid line numbers (at least 1) start_pos = math.max(1, start_pos) end_pos = math.max(1, end_pos) return start_pos, end_pos end ---Check if we're in a tree buffer and get the tree state ---@return table? tree_state, string? tree_type ("neo-tree" or "nvim-tree") function M.get_tree_state() local current_ft = "" -- Default fallback local current_win = 0 -- Default fallback -- Use pcall to handle test environments local state_success = pcall(function() current_ft = vim.bo.filetype or "" current_win = vim.api.nvim_get_current_win() end) if not state_success then return nil, nil end if current_ft == "neo-tree" then local manager_success, manager = pcall(require, "neo-tree.sources.manager") if not manager_success then return nil, nil end local state = manager.get_state("filesystem") if not state then return nil, nil end -- Validate we're in the correct neo-tree window if state.winid and state.winid == current_win then return state, "neo-tree" else return nil, nil end elseif current_ft == "NvimTree" then local api_success, nvim_tree_api = pcall(require, "nvim-tree.api") if not api_success then return nil, nil end return nvim_tree_api, "nvim-tree" elseif current_ft == "oil" then local oil_success, oil = pcall(require, "oil") if not oil_success then return nil, nil end return oil, "oil" else return nil, nil end end ---Create a visual command wrapper that follows neo-tree patterns ---@param normal_handler function The normal command handler ---@param visual_handler function The visual command handler ---@return function wrapped_func The wrapped command function function M.create_visual_command_wrapper(normal_handler, visual_handler) return function(...) local current_mode = get_current_mode(true) if current_mode == "v" or current_mode == "V" or current_mode == "\022" then -- Use the neo-tree pattern: exit visual mode, then schedule execution M.exit_visual_and_schedule(visual_handler, ...) else normal_handler(...) end end end ---Capture visual selection data while still in visual mode ---@return table|nil visual_data Captured data or nil if not in visual mode function M.capture_visual_selection_data() local valid = M.validate_visual_mode() if not valid then return nil end local tree_state, tree_type = M.get_tree_state() if not tree_state then return nil end local start_pos, end_pos = M.get_visual_range() -- Validate that we have a meaningful range if start_pos == 0 or end_pos == 0 then return nil end return { tree_state = tree_state, tree_type = tree_type, start_pos = start_pos, end_pos = end_pos, } end ---Extract files from visual selection in tree buffers ---@param visual_data table? Pre-captured visual selection data ---@return table files List of file paths ---@return string? error Error message if failed function M.get_files_from_visual_selection(visual_data) -- If we have pre-captured data, use it; otherwise try to get current data local tree_state, tree_type, start_pos, end_pos if visual_data then tree_state = visual_data.tree_state tree_type = visual_data.tree_type start_pos = visual_data.start_pos end_pos = visual_data.end_pos else local valid, err = M.validate_visual_mode() if not valid then return {}, err end tree_state, tree_type = M.get_tree_state() if not tree_state then return {}, "Not in a supported tree buffer" end start_pos, end_pos = M.get_visual_range() end if not tree_state then return {}, "Not in a supported tree buffer" end local files = {} if tree_type == "neo-tree" then local selected_nodes = {} for line = start_pos, end_pos do -- Neo-tree's tree:get_node() uses the line number directly (1-based) local node = tree_state.tree:get_node(line) if node then if node.type and node.type ~= "message" then table.insert(selected_nodes, node) end end end for _, node in ipairs(selected_nodes) do if node.type == "file" and node.path and node.path ~= "" then local depth = (node.get_depth and node:get_depth()) or 0 if depth > 1 then table.insert(files, node.path) end elseif node.type == "directory" and node.path and node.path ~= "" then local depth = (node.get_depth and node:get_depth()) or 0 if depth > 1 then table.insert(files, node.path) end end end elseif tree_type == "nvim-tree" then -- For nvim-tree, we need to manually map visual lines to tree nodes -- since nvim-tree doesn't have direct line-to-node mapping like neo-tree require("claudecode.logger").debug( "visual_commands", "Processing nvim-tree visual selection from line", start_pos, "to", end_pos ) local nvim_tree_api = tree_state local current_buf = vim.api.nvim_get_current_buf() -- Get all lines in the visual selection local lines = vim.api.nvim_buf_get_lines(current_buf, start_pos - 1, end_pos, false) require("claudecode.logger").debug("visual_commands", "Found", #lines, "lines in visual selection") -- For each line in the visual selection, try to get the corresponding node for i, _ in ipairs(lines) do local line_num = start_pos + i - 1 -- Set cursor to this line to get the node pcall(vim.api.nvim_win_set_cursor, 0, { line_num, 0 }) -- Get node under cursor for this line local node_success, node = pcall(nvim_tree_api.tree.get_node_under_cursor) if node_success and node then require("claudecode.logger").debug( "visual_commands", "Line", line_num, "node type:", node.type, "path:", node.absolute_path ) if node.type == "file" and node.absolute_path and node.absolute_path ~= "" then -- Check if it's not a root-level file (basic protection) if not string.match(node.absolute_path, "^/[^/]*$") then table.insert(files, node.absolute_path) end elseif node.type == "directory" and node.absolute_path and node.absolute_path ~= "" then table.insert(files, node.absolute_path) end else require("claudecode.logger").debug("visual_commands", "No valid node found for line", line_num) end end require("claudecode.logger").debug("visual_commands", "Extracted", #files, "files from nvim-tree visual selection") -- Remove duplicates while preserving order local seen = {} local unique_files = {} for _, file_path in ipairs(files) do if not seen[file_path] then seen[file_path] = true table.insert(unique_files, file_path) end end files = unique_files elseif tree_type == "oil" then local oil = tree_state local bufnr = vim.api.nvim_get_current_buf() -- Get current directory once local dir_ok, current_dir = pcall(oil.get_current_dir, bufnr) if dir_ok and current_dir then -- Access the process_oil_entry function through a module method for line = start_pos, end_pos do local entry_ok, entry = pcall(oil.get_entry_on_line, bufnr, line) if entry_ok and entry and entry.name then -- Skip parent directory entries if entry.name ~= ".." and entry.name ~= "." then local full_path = current_dir .. entry.name -- Handle various entry types if entry.type == "file" or entry.type == "link" then table.insert(files, full_path) elseif entry.type == "directory" then -- Ensure directory paths end with / table.insert(files, full_path:match("/$") and full_path or full_path .. "/") else -- For unknown types, return the path anyway table.insert(files, full_path) end end end end end end return files, nil end return M ================================================ FILE: lua/claudecode/server/client.lua ================================================ ---@brief WebSocket client connection management local frame = require("claudecode.server.frame") local handshake = require("claudecode.server.handshake") local logger = require("claudecode.logger") local M = {} ---@class WebSocketClient ---@field id string Unique client identifier ---@field tcp_handle table The vim.loop TCP handle ---@field state string Connection state: "connecting", "connected", "closing", "closed" ---@field buffer string Incoming data buffer ---@field handshake_complete boolean Whether WebSocket handshake is complete ---@field last_ping number Timestamp of last ping sent ---@field last_pong number Timestamp of last pong received ---Create a new WebSocket client ---@param tcp_handle table The vim.loop TCP handle ---@return WebSocketClient client The client object function M.create_client(tcp_handle) local client_id = tostring(tcp_handle):gsub("userdata: ", "client_") local client = { id = client_id, tcp_handle = tcp_handle, state = "connecting", buffer = "", handshake_complete = false, last_ping = 0, last_pong = vim.loop.now(), } return client end ---Process incoming data for a client ---@param client WebSocketClient The client object ---@param data string The incoming data ---@param on_message function Callback for complete messages: function(client, message_text) ---@param on_close function Callback for client close: function(client, code, reason) ---@param on_error function Callback for errors: function(client, error_msg) ---@param auth_token string|nil Expected authentication token for validation function M.process_data(client, data, on_message, on_close, on_error, auth_token) client.buffer = client.buffer .. data if not client.handshake_complete then local complete, request, remaining = handshake.extract_http_request(client.buffer) if complete and request then logger.debug("client", "Processing WebSocket handshake for client:", client.id) -- Log if auth token is required if auth_token then logger.debug("client", "Authentication required for client:", client.id) else logger.debug("client", "No authentication required for client:", client.id) end local success, response_from_handshake, _ = handshake.process_handshake(request, auth_token) -- Log authentication results if success then if auth_token then logger.debug("client", "Client authenticated successfully:", client.id) else logger.debug("client", "Client handshake completed (no auth required):", client.id) end else -- Log specific authentication failure details if auth_token and response_from_handshake:find("auth") then logger.warn( "client", "Authentication failed for client " .. client.id .. ": " .. (response_from_handshake:match("Bad WebSocket upgrade request: (.+)") or "unknown auth error") ) else logger.warn( "client", "WebSocket handshake failed for client " .. client.id .. ": " .. (response_from_handshake:match("HTTP/1.1 %d+ (.+)") or "unknown handshake error") ) end end client.tcp_handle:write(response_from_handshake, function(err) if err then logger.error("client", "Failed to send handshake response to client " .. client.id .. ": " .. err) on_error(client, "Failed to send handshake response: " .. err) return end if success then client.handshake_complete = true client.state = "connected" client.buffer = remaining logger.debug("client", "WebSocket connection established for client:", client.id) if #client.buffer > 0 then M.process_data(client, "", on_message, on_close, on_error, auth_token) end else client.state = "closing" logger.debug("client", "Closing connection for client due to failed handshake:", client.id) vim.schedule(function() client.tcp_handle:close() end) end end) end return end while #client.buffer >= 2 do -- Minimum frame size local parsed_frame, bytes_consumed = frame.parse_frame(client.buffer) if not parsed_frame then break end -- Frame validation is now handled entirely within frame.parse_frame. -- If frame.parse_frame returns a frame, it's considered valid. client.buffer = client.buffer:sub(bytes_consumed + 1) if parsed_frame.opcode == frame.OPCODE.TEXT then vim.schedule(function() on_message(client, parsed_frame.payload) end) elseif parsed_frame.opcode == frame.OPCODE.BINARY then -- Binary message (treat as text for JSON-RPC) vim.schedule(function() on_message(client, parsed_frame.payload) end) elseif parsed_frame.opcode == frame.OPCODE.CLOSE then local code = 1000 local reason = "" if #parsed_frame.payload >= 2 then local payload = parsed_frame.payload code = payload:byte(1) * 256 + payload:byte(2) if #payload > 2 then reason = payload:sub(3) end end if client.state == "connected" then local close_frame = frame.create_close_frame(code, reason) client.tcp_handle:write(close_frame) client.state = "closing" end vim.schedule(function() on_close(client, code, reason) end) elseif parsed_frame.opcode == frame.OPCODE.PING then local pong_frame = frame.create_pong_frame(parsed_frame.payload) client.tcp_handle:write(pong_frame) elseif parsed_frame.opcode == frame.OPCODE.PONG then client.last_pong = vim.loop.now() elseif parsed_frame.opcode == frame.OPCODE.CONTINUATION then -- Continuation frame - for simplicity, we don't support fragmentation on_error(client, "Fragmented messages not supported") M.close_client(client, 1003, "Unsupported data") else on_error(client, "Unknown WebSocket opcode: " .. parsed_frame.opcode) M.close_client(client, 1002, "Protocol error") end end end ---Send a text message to a client ---@param client WebSocketClient The client object ---@param message string The message to send ---@param callback function? Optional callback: function(err) function M.send_message(client, message, callback) if client.state ~= "connected" then if callback then callback("Client not connected") end return end local text_frame = frame.create_text_frame(message) client.tcp_handle:write(text_frame, callback) end ---Send a ping to a client ---@param client WebSocketClient The client object ---@param data string|nil Optional ping data function M.send_ping(client, data) if client.state ~= "connected" then return end local ping_frame = frame.create_ping_frame(data or "") client.tcp_handle:write(ping_frame) client.last_ping = vim.loop.now() end ---Close a client connection ---@param client WebSocketClient The client object ---@param code number|nil Close code (default: 1000) ---@param reason string|nil Close reason function M.close_client(client, code, reason) if client.state == "closed" or client.state == "closing" then return end code = code or 1000 reason = reason or "" if client.handshake_complete then local close_frame = frame.create_close_frame(code, reason) client.tcp_handle:write(close_frame, function() client.state = "closed" client.tcp_handle:close() end) else client.state = "closed" client.tcp_handle:close() end client.state = "closing" end ---Check if a client connection is alive ---@param client WebSocketClient The client object ---@param timeout number Timeout in milliseconds (default: 30000) ---@return boolean alive True if the client is considered alive function M.is_client_alive(client, timeout) timeout = timeout or 30000 -- 30 seconds default if client.state ~= "connected" then return false end local now = vim.loop.now() return (now - client.last_pong) < timeout end ---Get client info for debugging ---@param client WebSocketClient The client object ---@return table info Client information function M.get_client_info(client) return { id = client.id, state = client.state, handshake_complete = client.handshake_complete, buffer_size = #client.buffer, last_ping = client.last_ping, last_pong = client.last_pong, } end return M ================================================ FILE: lua/claudecode/server/frame.lua ================================================ ---@brief WebSocket frame encoding and decoding (RFC 6455) local utils = require("claudecode.server.utils") local M = {} -- WebSocket opcodes M.OPCODE = { CONTINUATION = 0x0, TEXT = 0x1, BINARY = 0x2, CLOSE = 0x8, PING = 0x9, PONG = 0xA, } ---@class WebSocketFrame ---@field fin boolean Final fragment flag ---@field opcode number Frame opcode ---@field masked boolean Mask flag ---@field payload_length number Length of payload data ---@field mask string|nil 4-byte mask (if masked) ---@field payload string Frame payload data ---Parse a WebSocket frame from binary data ---@param data string The binary frame data ---@return WebSocketFrame|nil frame The parsed frame, or nil if incomplete/invalid ---@return number bytes_consumed Number of bytes consumed from input function M.parse_frame(data) if type(data) ~= "string" then return nil, 0 end if #data < 2 then return nil, 0 -- Need at least 2 bytes for basic header end local pos = 1 local byte1 = data:byte(pos) local byte2 = data:byte(pos + 1) -- Validate byte values if not byte1 or not byte2 then return nil, 0 end pos = pos + 2 local fin = math.floor(byte1 / 128) == 1 local rsv1 = math.floor((byte1 % 128) / 64) == 1 local rsv2 = math.floor((byte1 % 64) / 32) == 1 local rsv3 = math.floor((byte1 % 32) / 16) == 1 local opcode = byte1 % 16 local masked = math.floor(byte2 / 128) == 1 local payload_len = byte2 % 128 -- Validate opcode (RFC 6455 Section 5.2) local valid_opcodes = { [M.OPCODE.CONTINUATION] = true, [M.OPCODE.TEXT] = true, [M.OPCODE.BINARY] = true, [M.OPCODE.CLOSE] = true, [M.OPCODE.PING] = true, [M.OPCODE.PONG] = true, } if not valid_opcodes[opcode] then return nil, 0 -- Invalid opcode end -- Check for reserved bits (must be 0) if rsv1 or rsv2 or rsv3 then return nil, 0 -- Protocol error end -- Control frames must have fin=1 and payload ≤ 125 (RFC 6455 Section 5.5) if opcode >= M.OPCODE.CLOSE then if not fin or payload_len > 125 then return nil, 0 -- Protocol violation end end -- Determine actual payload length local actual_payload_len = payload_len if payload_len == 126 then if #data < pos + 1 then return nil, 0 -- Need 2 more bytes end actual_payload_len = utils.bytes_to_uint16(data:sub(pos, pos + 1)) pos = pos + 2 -- Allow any valid 16-bit length for compatibility -- Note: Technically should be > 125, but some implementations may vary elseif payload_len == 127 then if #data < pos + 7 then return nil, 0 -- Need 8 more bytes end actual_payload_len = utils.bytes_to_uint64(data:sub(pos, pos + 7)) pos = pos + 8 -- Allow any valid 64-bit length for compatibility -- Note: Technically should be > 65535, but some implementations may vary -- Prevent extremely large payloads (DOS protection) if actual_payload_len > 100 * 1024 * 1024 then -- 100MB limit return nil, 0 end end -- Additional payload length validation if actual_payload_len < 0 then return nil, 0 -- Invalid negative length end -- Read mask if present local mask = nil if masked then if #data < pos + 3 then return nil, 0 -- Need 4 mask bytes end mask = data:sub(pos, pos + 3) pos = pos + 4 end -- Check if we have enough data for payload if #data < pos + actual_payload_len - 1 then return nil, 0 -- Incomplete frame end -- Read payload local payload = data:sub(pos, pos + actual_payload_len - 1) pos = pos + actual_payload_len -- Unmask payload if needed if masked and mask then payload = utils.apply_mask(payload, mask) end -- Validate text frame payload is valid UTF-8 if opcode == M.OPCODE.TEXT and not utils.is_valid_utf8(payload) then return nil, 0 -- Invalid UTF-8 in text frame end -- Basic validation for close frame payload if opcode == M.OPCODE.CLOSE and actual_payload_len > 0 then if actual_payload_len == 1 then return nil, 0 -- Close frame with 1 byte payload is invalid end -- Allow most close codes for compatibility, only validate UTF-8 for reason text if actual_payload_len > 2 then local reason = payload:sub(3) if not utils.is_valid_utf8(reason) then return nil, 0 -- Invalid UTF-8 in close reason end end end local frame = { fin = fin, opcode = opcode, masked = masked, payload_length = actual_payload_len, mask = mask, payload = payload, } return frame, pos - 1 end ---Create a WebSocket frame ---@param opcode number Frame opcode ---@param payload string Frame payload ---@param fin boolean|nil Final fragment flag (default: true) ---@param masked boolean|nil Whether to mask the frame (default: false for server) ---@return string frame_data The encoded frame data function M.create_frame(opcode, payload, fin, masked) fin = fin ~= false -- Default to true masked = masked == true -- Default to false local frame_data = {} -- First byte: FIN + RSV + Opcode local byte1 = opcode if fin then byte1 = byte1 + 128 -- Set FIN bit (0x80) end table.insert(frame_data, string.char(byte1)) -- Payload length and mask bit local payload_len = #payload local byte2 = 0 if masked then byte2 = byte2 + 128 -- Set MASK bit (0x80) end if payload_len < 126 then byte2 = byte2 + payload_len table.insert(frame_data, string.char(byte2)) elseif payload_len < 65536 then byte2 = byte2 + 126 table.insert(frame_data, string.char(byte2)) table.insert(frame_data, utils.uint16_to_bytes(payload_len)) else byte2 = byte2 + 127 table.insert(frame_data, string.char(byte2)) table.insert(frame_data, utils.uint64_to_bytes(payload_len)) end -- Add mask if needed local mask = nil if masked then -- Generate random 4-byte mask mask = string.char(math.random(0, 255), math.random(0, 255), math.random(0, 255), math.random(0, 255)) table.insert(frame_data, mask) end -- Add payload (masked if needed) if masked and mask then payload = utils.apply_mask(payload, mask) end table.insert(frame_data, payload) return table.concat(frame_data) end ---Create a text frame ---@param text string The text to send ---@param fin boolean|nil Final fragment flag (default: true) ---@return string frame_data The encoded frame data function M.create_text_frame(text, fin) return M.create_frame(M.OPCODE.TEXT, text, fin, false) end ---Create a binary frame ---@param data string The binary data to send ---@param fin boolean|nil Final fragment flag (default: true) ---@return string frame_data The encoded frame data function M.create_binary_frame(data, fin) return M.create_frame(M.OPCODE.BINARY, data, fin, false) end ---Create a close frame ---@param code number|nil Close code (default: 1000) ---@param reason string|nil Close reason (default: empty) ---@return string frame_data The encoded frame data function M.create_close_frame(code, reason) code = code or 1000 reason = reason or "" local payload = utils.uint16_to_bytes(code) .. reason return M.create_frame(M.OPCODE.CLOSE, payload, true, false) end ---Create a ping frame ---@param data string|nil Ping data (default: empty) ---@return string frame_data The encoded frame data function M.create_ping_frame(data) data = data or "" return M.create_frame(M.OPCODE.PING, data, true, false) end ---Create a pong frame ---@param data string|nil Pong data (should match ping data) ---@return string frame_data The encoded frame data function M.create_pong_frame(data) data = data or "" return M.create_frame(M.OPCODE.PONG, data, true, false) end ---Check if an opcode is a control frame ---@param opcode number The opcode to check ---@return boolean is_control True if it's a control frame function M.is_control_frame(opcode) return opcode >= 0x8 end ---Validate a WebSocket frame ---@param frame WebSocketFrame The frame to validate ---@return boolean valid True if the frame is valid ---@return string|nil error Error message if invalid function M.validate_frame(frame) -- Control frames must not be fragmented if M.is_control_frame(frame.opcode) and not frame.fin then return false, "Control frames must not be fragmented" end -- Control frames must have payload <= 125 bytes if M.is_control_frame(frame.opcode) and frame.payload_length > 125 then return false, "Control frame payload too large" end -- Check for valid opcodes local valid_opcodes = { [M.OPCODE.CONTINUATION] = true, [M.OPCODE.TEXT] = true, [M.OPCODE.BINARY] = true, [M.OPCODE.CLOSE] = true, [M.OPCODE.PING] = true, [M.OPCODE.PONG] = true, } if not valid_opcodes[frame.opcode] then return false, "Invalid opcode: " .. frame.opcode end -- Text frames must contain valid UTF-8 if frame.opcode == M.OPCODE.TEXT and not utils.is_valid_utf8(frame.payload) then return false, "Text frame contains invalid UTF-8" end return true end return M ================================================ FILE: lua/claudecode/server/handshake.lua ================================================ ---@brief WebSocket handshake handling (RFC 6455) local utils = require("claudecode.server.utils") local M = {} ---Check if an HTTP request is a valid WebSocket upgrade request ---@param request string The HTTP request string ---@param expected_auth_token string|nil Expected authentication token for validation ---@return boolean valid True if it's a valid WebSocket upgrade request ---@return table|string headers_or_error Headers table if valid, error message if not function M.validate_upgrade_request(request, expected_auth_token) local headers = utils.parse_http_headers(request) -- Check for required headers if not headers["upgrade"] or headers["upgrade"]:lower() ~= "websocket" then return false, "Missing or invalid Upgrade header" end if not headers["connection"] or not headers["connection"]:lower():find("upgrade") then return false, "Missing or invalid Connection header" end if not headers["sec-websocket-key"] then return false, "Missing Sec-WebSocket-Key header" end if not headers["sec-websocket-version"] or headers["sec-websocket-version"] ~= "13" then return false, "Missing or unsupported Sec-WebSocket-Version header" end -- Validate WebSocket key format (should be base64 encoded 16 bytes) local key = headers["sec-websocket-key"] if #key ~= 24 then -- Base64 encoded 16 bytes = 24 characters return false, "Invalid Sec-WebSocket-Key format" end -- Validate authentication token if required if expected_auth_token then -- Check if expected_auth_token is valid if type(expected_auth_token) ~= "string" or expected_auth_token == "" then return false, "Server configuration error: invalid expected authentication token" end local auth_header = headers["x-claude-code-ide-authorization"] if not auth_header then return false, "Missing authentication header: x-claude-code-ide-authorization" end -- Check for empty auth header if auth_header == "" then return false, "Authentication token too short (min 10 characters)" end -- Check for suspicious auth header lengths if #auth_header > 500 then return false, "Authentication token too long (max 500 characters)" end if #auth_header < 10 then return false, "Authentication token too short (min 10 characters)" end if auth_header ~= expected_auth_token then return false, "Invalid authentication token" end end return true, headers end ---Generate a WebSocket handshake response ---@param client_key string The client's Sec-WebSocket-Key header value ---@param protocol string|nil Optional subprotocol to accept ---@return string|nil response The HTTP response string, or nil on error function M.create_handshake_response(client_key, protocol) local accept_key = utils.generate_accept_key(client_key) if not accept_key then return nil end local response_lines = { "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", "Sec-WebSocket-Accept: " .. accept_key, } if protocol then table.insert(response_lines, "Sec-WebSocket-Protocol: " .. protocol) end -- Add empty line to end headers table.insert(response_lines, "") table.insert(response_lines, "") return table.concat(response_lines, "\r\n") end ---Parse the HTTP request line ---@param request string The HTTP request string ---@return string|nil method The HTTP method (GET, POST, etc.) ---@return string|nil path The request path ---@return string|nil version The HTTP version function M.parse_request_line(request) local first_line = request:match("^([^\r\n]+)") if not first_line then return nil, nil, nil end local method, path, version = first_line:match("^(%S+)%s+(%S+)%s+(%S+)$") return method, path, version end ---Check if the request is for the WebSocket endpoint ---@param request string The HTTP request string ---@return boolean valid True if the request is for a valid WebSocket endpoint function M.is_websocket_endpoint(request) local method, path, version = M.parse_request_line(request) -- Must be GET request if method ~= "GET" then return false end -- Must be HTTP/1.1 or later if not version or not version:match("^HTTP/1%.1") then return false end -- Accept any path for now (could be made configurable) if not path then return false end return true end ---Create a WebSocket handshake error response ---@param code number HTTP status code ---@param message string Error message ---@return string response The HTTP error response function M.create_error_response(code, message) local status_text = { [400] = "Bad Request", [404] = "Not Found", [426] = "Upgrade Required", [500] = "Internal Server Error", } local status = status_text[code] or "Error" local response_lines = { "HTTP/1.1 " .. code .. " " .. status, "Content-Type: text/plain", "Content-Length: " .. #message, "Connection: close", "", message, } return table.concat(response_lines, "\r\n") end ---Process a complete WebSocket handshake ---@param request string The HTTP request string ---@param expected_auth_token string|nil Expected authentication token for validation ---@return boolean success True if handshake was successful ---@return string response The HTTP response to send ---@return table|nil headers The parsed headers if successful function M.process_handshake(request, expected_auth_token) -- Check if it's a valid WebSocket endpoint request if not M.is_websocket_endpoint(request) then local response = M.create_error_response(404, "WebSocket endpoint not found") return false, response, nil end -- Validate the upgrade request local is_valid_upgrade, validation_payload = M.validate_upgrade_request(request, expected_auth_token) ---@type boolean, table|string if not is_valid_upgrade then assert(type(validation_payload) == "string", "validation_payload should be a string on error") local error_message = validation_payload local response = M.create_error_response(400, "Bad WebSocket upgrade request: " .. error_message) return false, response, nil end -- If is_valid_upgrade is true, validation_payload must be the headers table assert(type(validation_payload) == "table", "validation_payload should be a table on success") local headers_table = validation_payload -- Generate handshake response local client_key = headers_table["sec-websocket-key"] local protocol = headers_table["sec-websocket-protocol"] -- Optional local response = M.create_handshake_response(client_key, protocol) if not response then local error_response = M.create_error_response(500, "Failed to generate WebSocket handshake response") return false, error_response, nil -- error_response is string, nil is for headers end return true, response, headers_table -- headers_table is 'table', compatible with 'table|nil' end ---Check if a request buffer contains a complete HTTP request ---@param buffer string The request buffer ---@return boolean complete True if the request is complete ---@return string|nil request The complete request if found ---@return string remaining Any remaining data after the request function M.extract_http_request(buffer) -- Look for the end of HTTP headers (double CRLF) local header_end = buffer:find("\r\n\r\n") if not header_end then return false, nil, buffer end -- For WebSocket upgrade, there should be no body local request = buffer:sub(1, header_end + 3) -- Include the final CRLF local remaining = buffer:sub(header_end + 4) return true, request, remaining end return M ================================================ FILE: lua/claudecode/server/init.lua ================================================ ---@brief WebSocket server for Claude Code Neovim integration local claudecode_main = require("claudecode") -- Added for version access local logger = require("claudecode.logger") local tcp_server = require("claudecode.server.tcp") local tools = require("claudecode.tools.init") -- Added: Require the tools module local MCP_PROTOCOL_VERSION = "2024-11-05" local M = {} ---@class ServerState ---@field server table|nil The TCP server instance ---@field port number|nil The port server is running on ---@field auth_token string|nil The authentication token for validating connections ---@field clients table A list of connected clients ---@field handlers table Message handlers by method name ---@field ping_timer table|nil Timer for sending pings M.state = { server = nil, port = nil, auth_token = nil, clients = {}, handlers = {}, ping_timer = nil, } ---Initialize the WebSocket server ---@param config table Configuration options ---@param auth_token string|nil The authentication token for validating connections ---@return boolean success Whether server started successfully ---@return number|string port_or_error Port number or error message function M.start(config, auth_token) if M.state.server then return false, "Server already running" end M.state.auth_token = auth_token -- Log authentication state if auth_token then logger.debug("server", "Starting WebSocket server with authentication enabled") logger.debug("server", "Auth token length:", #auth_token) else logger.debug("server", "Starting WebSocket server WITHOUT authentication (insecure)") end M.register_handlers() tools.setup(M) local callbacks = { on_message = function(client, message) M._handle_message(client, message) end, on_connect = function(client) M.state.clients[client.id] = client -- Log connection with auth status if M.state.auth_token then logger.debug("server", "Authenticated WebSocket client connected:", client.id) else logger.debug("server", "WebSocket client connected (no auth):", client.id) end -- Notify main module about new connection for queue processing local main_module = require("claudecode") if main_module.process_mention_queue then vim.schedule(function() main_module.process_mention_queue(true) end) end end, on_disconnect = function(client, code, reason) M.state.clients[client.id] = nil logger.debug( "server", "WebSocket client disconnected:", client.id, "(code:", code, ", reason:", (reason or "N/A") .. ")" ) end, on_error = function(error_msg) logger.error("server", "WebSocket server error:", error_msg) end, } local server, error_msg = tcp_server.create_server(config, callbacks, M.state.auth_token) if not server then return false, error_msg or "Unknown server creation error" end M.state.server = server M.state.port = server.port M.state.ping_timer = tcp_server.start_ping_timer(server, 30000) -- Start ping timer to keep connections alive return true, server.port end ---Stop the WebSocket server ---@return boolean success Whether server stopped successfully ---@return string|nil error_message Error message if any function M.stop() if not M.state.server then return false, "Server not running" end if M.state.ping_timer then M.state.ping_timer:stop() M.state.ping_timer:close() M.state.ping_timer = nil end tcp_server.stop_server(M.state.server) -- CRITICAL: Clear global deferred responses to prevent memory leaks and hanging if _G.claude_deferred_responses then _G.claude_deferred_responses = {} end M.state.server = nil M.state.port = nil M.state.auth_token = nil M.state.clients = {} return true end ---Handle incoming WebSocket message ---@param client table The client that sent the message ---@param message string The JSON-RPC message function M._handle_message(client, message) local success, parsed = pcall(vim.json.decode, message) if not success then M.send_response(client, nil, nil, { code = -32700, message = "Parse error", data = "Invalid JSON", }) return end if type(parsed) ~= "table" or parsed.jsonrpc ~= "2.0" then M.send_response(client, parsed.id, nil, { code = -32600, message = "Invalid Request", data = "Not a valid JSON-RPC 2.0 request", }) return end if parsed.id then M._handle_request(client, parsed) else M._handle_notification(client, parsed) end end ---Handle JSON-RPC request (requires response) ---@param client table The client that sent the request ---@param request table The parsed JSON-RPC request function M._handle_request(client, request) local method = request.method local params = request.params or {} local id = request.id local handler = M.state.handlers[method] if not handler then M.send_response(client, id, nil, { code = -32601, message = "Method not found", data = "Unknown method: " .. tostring(method), }) return end local success, result, error_data = pcall(handler, client, params) if success then -- Check if this is a deferred response (blocking tool) if result and result._deferred then logger.debug("server", "Handler returned deferred response - storing for later") -- Store the request info for later response local deferred_info = { client = result.client, id = id, coroutine = result.coroutine, method = method, params = result.params, } -- Set up the completion callback M._setup_deferred_response(deferred_info) return -- Don't send response now end if error_data then M.send_response(client, id, nil, error_data) else M.send_response(client, id, result, nil) end else M.send_response(client, id, nil, { code = -32603, message = "Internal error", data = tostring(result), -- result contains error message when pcall fails }) end end -- Add a unique module ID to detect reloading local module_instance_id = math.random(10000, 99999) logger.debug("server", "Server module loaded with instance ID:", module_instance_id) -- Note: debug_deferred_table function removed as deferred_responses table is no longer used function M._setup_deferred_response(deferred_info) local co = deferred_info.coroutine logger.debug("server", "Setting up deferred response for coroutine:", tostring(co)) logger.debug("server", "Storage happening in module instance:", module_instance_id) -- Create a response sender function that captures the current server instance local response_sender = function(result) logger.debug("server", "Deferred response triggered for coroutine:", tostring(co)) if result and result.content then -- MCP-compliant response M.send_response(deferred_info.client, deferred_info.id, result, nil) elseif result and result.error then -- Error response M.send_response(deferred_info.client, deferred_info.id, nil, result.error) else -- Fallback error M.send_response(deferred_info.client, deferred_info.id, nil, { code = -32603, message = "Internal error", data = "Deferred response completed with unexpected format", }) end end -- Store the response sender in a global location that won't be affected by module reloading if not _G.claude_deferred_responses then _G.claude_deferred_responses = {} end _G.claude_deferred_responses[tostring(co)] = response_sender logger.debug("server", "Stored response sender in global table for coroutine:", tostring(co)) end ---Handle JSON-RPC notification (no response) ---@param client table The client that sent the notification ---@param notification table The parsed JSON-RPC notification function M._handle_notification(client, notification) local method = notification.method local params = notification.params or {} local handler = M.state.handlers[method] if handler then pcall(handler, client, params) end end ---Register message handlers for the server function M.register_handlers() M.state.handlers = { ["initialize"] = function(client, params) return { protocolVersion = MCP_PROTOCOL_VERSION, capabilities = { logging = vim.empty_dict(), -- Ensure this is an object {} not an array [] prompts = { listChanged = true }, resources = { subscribe = true, listChanged = true }, tools = { listChanged = true }, }, serverInfo = { name = "claudecode-neovim", version = claudecode_main.version:string(), }, } end, ["notifications/initialized"] = function(client, params) -- Added handler for initialized notification end, ["prompts/list"] = function(client, params) -- Added handler for prompts/list return { prompts = {}, -- This will be encoded as an empty JSON array } end, ["tools/list"] = function(client, params) return { tools = tools.get_tool_list(), } end, ["tools/call"] = function(client, params) logger.debug( "server", "Received tools/call. Tool: ", params and params.name, " Arguments: ", vim.inspect(params and params.arguments) ) local result_or_error_table = tools.handle_invoke(client, params) -- Check if this is a deferred response (blocking tool) if result_or_error_table and result_or_error_table._deferred then logger.debug("server", "Tool is blocking - setting up deferred response") -- Return the deferred response directly - _handle_request will process it return result_or_error_table end -- Log the response for debugging logger.debug("server", "Response - tools/call", params and params.name .. ":", vim.inspect(result_or_error_table)) if result_or_error_table.error then return nil, result_or_error_table.error elseif result_or_error_table.result then return result_or_error_table.result, nil else -- Should not happen if tools.handle_invoke behaves correctly return nil, { code = -32603, message = "Internal error", data = "Tool handler returned unexpected format", } end end, } end ---Send a message to a client ---@param client table The client to send to ---@param method string The method name ---@param params table|nil The parameters to send ---@return boolean success Whether message was sent successfully function M.send(client, method, params) if not M.state.server then return false end local message = { jsonrpc = "2.0", method = method, params = params or vim.empty_dict(), } local json_message = vim.json.encode(message) tcp_server.send_to_client(M.state.server, client.id, json_message) return true end ---Send a response to a client ---@param client WebSocketClient The client to send to ---@param id number|string|nil The request ID to respond to ---@param result any|nil The result data if successful ---@param error_data table|nil The error data if failed ---@return boolean success Whether response was sent successfully function M.send_response(client, id, result, error_data) if not M.state.server then return false end local response = { jsonrpc = "2.0", id = id, } if error_data then response.error = error_data else response.result = result end local json_response = vim.json.encode(response) tcp_server.send_to_client(M.state.server, client.id, json_response) return true end ---Broadcast a message to all connected clients ---@param method string The method name ---@param params table|nil The parameters to send ---@return boolean success Whether broadcast was successful function M.broadcast(method, params) if not M.state.server then return false end local message = { jsonrpc = "2.0", method = method, params = params or vim.empty_dict(), } local json_message = vim.json.encode(message) tcp_server.broadcast(M.state.server, json_message) return true end ---Get server status information ---@return table status Server status information function M.get_status() if not M.state.server then return { running = false, port = nil, client_count = 0, } end return { running = true, port = M.state.port, client_count = tcp_server.get_client_count(M.state.server), clients = tcp_server.get_clients_info(M.state.server), } end return M ================================================ FILE: lua/claudecode/server/mock.lua ================================================ ---@brief [[ --- Mock WebSocket server implementation for testing. --- This module provides a minimal implementation of the WebSocket server --- functionality, suitable for testing or when real WebSocket connections --- are not available or needed. ---@brief ]] local M = {} local tools = require("claudecode.tools.init") --- Mock server state M.state = { server = nil, port = nil, clients = {}, handlers = {}, messages = {}, -- Store messages for testing } ---Find an available port in the given range ---@param min number The minimum port number ---@param max number The maximum port number ---@return number port The selected port function M.find_available_port(min, max) -- For mock implementation, just return the minimum port -- In a real implementation, this would scan for available ports in the range return min end ---Start the WebSocket server ---@param config table Configuration options ---@return boolean success Whether the server started successfully ---@return number|string port_or_error The port number or error message function M.start(config) if M.state.server then -- Already running return false, "Server already running" end -- Find an available port local port = M.find_available_port(config.port_range.min, config.port_range.max) if not port then return false, "No available ports found" end -- Store the port in state M.state.port = port -- Create mock server object M.state.server = { port = port, clients = {}, on_message = function() end, on_connect = function() end, on_disconnect = function() end, } -- Register message handlers M.register_handlers() return true, port end ---Stop the WebSocket server ---@return boolean success Whether the server stopped successfully ---@return string|nil error Error message if failed function M.stop() if not M.state.server then -- Not running return false, "Server not running" end -- Reset state M.state.server = nil M.state.port = nil M.state.clients = {} M.state.messages = {} return true end --- Register message handlers function M.register_handlers() -- Default handlers M.state.handlers = { ["mcp.connect"] = function(client, params) -- Handle connection handshake -- Parameters not used in this mock implementation return { result = { message = "Connection established" } } end, ["mcp.tool.invoke"] = function(client, params) -- Handle tool invocation by dispatching to tools implementation return tools.handle_invoke(client, params) end, } end ---Add a client to the server ---@param client_id string A unique client identifier ---@return table client The client object function M.add_client(client_id) if not M.state.server then error("Server not running") end local client = { id = client_id, connected = true, messages = {}, } M.state.clients[client_id] = client return client end ---Remove a client from the server ---@param client_id string The client identifier ---@return boolean success Whether removal was successful function M.remove_client(client_id) if not M.state.server or not M.state.clients[client_id] then return false end M.state.clients[client_id] = nil return true end ---Send a message to a client ---@param client table|string The client object or ID ---@param method string The method name ---@param params table The parameters to send ---@return boolean success Whether sending was successful function M.send(client, method, params) local client_obj if type(client) == "string" then client_obj = M.state.clients[client] else client_obj = client end if not client_obj then return false end local message = { jsonrpc = "2.0", method = method, params = params, } -- Store for testing table.insert(client_obj.messages, message) table.insert(M.state.messages, { client = client_obj.id, direction = "outbound", message = message, }) return true end ---Send a response to a client ---@param client table|string The client object or ID ---@param id string The message ID ---@param result table|nil The result data ---@param error table|nil The error data ---@return boolean success Whether sending was successful function M.send_response(client, id, result, error) local client_obj if type(client) == "string" then client_obj = M.state.clients[client] else client_obj = client end if not client_obj then return false end local response = { jsonrpc = "2.0", id = id, } if error then response.error = error else response.result = result end -- Store for testing table.insert(client_obj.messages, response) table.insert(M.state.messages, { client = client_obj.id, direction = "outbound", message = response, }) return true end ---Broadcast a message to all connected clients ---@param method string The method name ---@param params table The parameters to send ---@return boolean success Whether broadcasting was successful function M.broadcast(method, params) local success = true for client_id, _ in pairs(M.state.clients) do local send_success = M.send(client_id, method, params) success = success and send_success end return success end ---Simulate receiving a message from a client ---@param client_id string The client ID ---@param message table The message to process ---@return table|nil response The response if any function M.simulate_message(client_id, message) local client = M.state.clients[client_id] if not client then return nil end -- Store the message table.insert(M.state.messages, { client = client_id, direction = "inbound", message = message, }) -- Process the message if message.method and M.state.handlers[message.method] then local handler = M.state.handlers[message.method] local response = handler(client, message.params) if message.id and response then -- If the message had an ID, this is a request and needs a response M.send_response(client, message.id, response.result, response.error) return response end end return nil end ---Clear test messages function M.clear_messages() M.state.messages = {} for _, client in pairs(M.state.clients) do client.messages = {} end end return M ================================================ FILE: lua/claudecode/server/tcp.lua ================================================ ---@brief TCP server implementation using vim.loop local client_manager = require("claudecode.server.client") local utils = require("claudecode.server.utils") local M = {} ---@class TCPServer ---@field server table The vim.loop TCP server handle ---@field port number The port the server is listening on ---@field auth_token string|nil The authentication token for validating connections ---@field clients table Table of connected clients ---@field on_message function Callback for WebSocket messages ---@field on_connect function Callback for new connections ---@field on_disconnect function Callback for client disconnections ---@field on_error fun(err_msg: string) Callback for errors ---Find an available port by attempting to bind ---@param min_port number Minimum port to try ---@param max_port number Maximum port to try ---@return number|nil port Available port number, or nil if none found function M.find_available_port(min_port, max_port) if min_port > max_port then return nil -- Or handle error appropriately end local ports = {} for i = min_port, max_port do table.insert(ports, i) end -- Shuffle the ports utils.shuffle_array(ports) -- Try to bind to a port from the shuffled list for _, port in ipairs(ports) do local test_server = vim.loop.new_tcp() if test_server then local success = test_server:bind("127.0.0.1", port) test_server:close() if success then return port end end -- Continue to next port if test_server creation failed or bind failed end return nil end ---Create and start a TCP server ---@param config table Server configuration ---@param callbacks table Callback functions ---@param auth_token string|nil Authentication token for validating connections ---@return TCPServer|nil server The server object, or nil on error ---@return string|nil error Error message if failed function M.create_server(config, callbacks, auth_token) local port = M.find_available_port(config.port_range.min, config.port_range.max) if not port then return nil, "No available ports in range " .. config.port_range.min .. "-" .. config.port_range.max end local tcp_server = vim.loop.new_tcp() if not tcp_server then return nil, "Failed to create TCP server" end -- Create server object local server = { server = tcp_server, port = port, auth_token = auth_token, clients = {}, on_message = callbacks.on_message or function() end, on_connect = callbacks.on_connect or function() end, on_disconnect = callbacks.on_disconnect or function() end, on_error = callbacks.on_error or function() end, } local bind_success, bind_err = tcp_server:bind("127.0.0.1", port) if not bind_success then tcp_server:close() return nil, "Failed to bind to port " .. port .. ": " .. (bind_err or "unknown error") end -- Start listening local listen_success, listen_err = tcp_server:listen(128, function(err) if err then callbacks.on_error("Listen error: " .. err) return end M._handle_new_connection(server) end) if not listen_success then tcp_server:close() return nil, "Failed to listen on port " .. port .. ": " .. (listen_err or "unknown error") end return server, nil end ---Handle a new client connection ---@param server TCPServer The server object function M._handle_new_connection(server) local client_tcp = vim.loop.new_tcp() if not client_tcp then server.on_error("Failed to create client TCP handle") return end local accept_success, accept_err = server.server:accept(client_tcp) if not accept_success then server.on_error("Failed to accept connection: " .. (accept_err or "unknown error")) client_tcp:close() return end -- Create WebSocket client wrapper local client = client_manager.create_client(client_tcp) server.clients[client.id] = client -- Set up data handler client_tcp:read_start(function(err, data) if err then server.on_error("Client read error: " .. err) M._remove_client(server, client) return end if not data then -- EOF - client disconnected M._remove_client(server, client) return end -- Process incoming data client_manager.process_data(client, data, function(cl, message) server.on_message(cl, message) end, function(cl, code, reason) server.on_disconnect(cl, code, reason) M._remove_client(server, cl) end, function(cl, error_msg) server.on_error("Client " .. cl.id .. " error: " .. error_msg) M._remove_client(server, cl) end, server.auth_token) end) -- Notify about new connection server.on_connect(client) end ---Remove a client from the server ---@param server TCPServer The server object ---@param client WebSocketClient The client to remove function M._remove_client(server, client) if server.clients[client.id] then server.clients[client.id] = nil if not client.tcp_handle:is_closing() then client.tcp_handle:close() end end end ---Send a message to a specific client ---@param server TCPServer The server object ---@param client_id string The client ID ---@param message string The message to send ---@param callback function|nil Optional callback function M.send_to_client(server, client_id, message, callback) local client = server.clients[client_id] if not client then if callback then callback("Client not found: " .. client_id) end return end client_manager.send_message(client, message, callback) end ---Broadcast a message to all connected clients ---@param server TCPServer The server object ---@param message string The message to broadcast function M.broadcast(server, message) for _, client in pairs(server.clients) do client_manager.send_message(client, message) end end ---Get the number of connected clients ---@param server TCPServer The server object ---@return number count Number of connected clients function M.get_client_count(server) local count = 0 for _ in pairs(server.clients) do count = count + 1 end return count end ---Get information about all clients ---@param server TCPServer The server object ---@return table clients Array of client information function M.get_clients_info(server) local clients = {} for _, client in pairs(server.clients) do table.insert(clients, client_manager.get_client_info(client)) end return clients end ---Close a specific client connection ---@param server TCPServer The server object ---@param client_id string The client ID ---@param code number|nil Close code ---@param reason string|nil Close reason function M.close_client(server, client_id, code, reason) local client = server.clients[client_id] if client then client_manager.close_client(client, code, reason) end end ---Stop the TCP server ---@param server TCPServer The server object function M.stop_server(server) -- Close all clients for _, client in pairs(server.clients) do client_manager.close_client(client, 1001, "Server shutting down") end -- Clear clients server.clients = {} -- Close server if server.server and not server.server:is_closing() then server.server:close() end end ---Start a periodic ping task to keep connections alive ---@param server TCPServer The server object ---@param interval number Ping interval in milliseconds (default: 30000) ---@return table? timer The timer handle, or nil if creation failed function M.start_ping_timer(server, interval) interval = interval or 30000 -- 30 seconds local timer = vim.loop.new_timer() if not timer then server.on_error("Failed to create ping timer") return nil end timer:start(interval, interval, function() for _, client in pairs(server.clients) do if client.state == "connected" then -- Check if client is alive if client_manager.is_client_alive(client, interval * 2) then client_manager.send_ping(client, "ping") else -- Client appears dead, close it server.on_error("Client " .. client.id .. " appears dead, closing") client_manager.close_client(client, 1006, "Connection timeout") M._remove_client(server, client) end end end end) return timer end return M ================================================ FILE: lua/claudecode/server/utils.lua ================================================ ---@brief Utility functions for WebSocket server implementation local M = {} -- Lua 5.1 compatible bitwise operations (arithmetic emulation). local function band(a, b) local result = 0 local bitval = 1 while a > 0 and b > 0 do if a % 2 == 1 and b % 2 == 1 then result = result + bitval end bitval = bitval * 2 a = math.floor(a / 2) b = math.floor(b / 2) end return result end local function bor(a, b) local result = 0 local bitval = 1 while a > 0 or b > 0 do if a % 2 == 1 or b % 2 == 1 then result = result + bitval end bitval = bitval * 2 a = math.floor(a / 2) b = math.floor(b / 2) end return result end local function bxor(a, b) local result = 0 local bitval = 1 while a > 0 or b > 0 do if (a % 2) ~= (b % 2) then result = result + bitval end bitval = bitval * 2 a = math.floor(a / 2) b = math.floor(b / 2) end return result end local function bnot(a) return bxor(a, 0xFFFFFFFF) end local function lshift(value, amount) local shifted_val = value * (2 ^ amount) return shifted_val % (2 ^ 32) end local function rshift(value, amount) return math.floor(value / (2 ^ amount)) end local function rotleft(value, amount) local mask = 0xFFFFFFFF value = band(value, mask) local part1 = lshift(value, amount) local part2 = rshift(value, 32 - amount) return band(bor(part1, part2), mask) end local function add32(a, b) local sum = a + b return band(sum, 0xFFFFFFFF) end ---Generate a random, spec-compliant WebSocket key. ---@return string key Base64 encoded 16-byte random nonce. function M.generate_websocket_key() local random_bytes = {} for _ = 1, 16 do random_bytes[#random_bytes + 1] = string.char(math.random(0, 255)) end return M.base64_encode(table.concat(random_bytes)) end ---Base64 encode a string ---@param data string The data to encode ---@return string encoded The base64 encoded string function M.base64_encode(data) local chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local result = {} local padding = "" local pad_len = 3 - (#data % 3) if pad_len ~= 3 then data = data .. string.rep("\0", pad_len) padding = string.rep("=", pad_len) end for i = 1, #data, 3 do local a, b, c = data:byte(i, i + 2) local bitmap = a * 65536 + b * 256 + c -- Use table for efficient string building result[#result + 1] = chars:sub(math.floor(bitmap / 262144) + 1, math.floor(bitmap / 262144) + 1) result[#result + 1] = chars:sub(math.floor((bitmap % 262144) / 4096) + 1, math.floor((bitmap % 262144) / 4096) + 1) result[#result + 1] = chars:sub(math.floor((bitmap % 4096) / 64) + 1, math.floor((bitmap % 4096) / 64) + 1) result[#result + 1] = chars:sub((bitmap % 64) + 1, (bitmap % 64) + 1) end local encoded = table.concat(result) return encoded:sub(1, #encoded - #padding) .. padding end ---Base64 decode a string ---@param data string The base64 encoded string ---@return string|nil decoded The decoded string, or nil on error (e.g. invalid char) function M.base64_decode(data) local chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local lookup = {} for i = 1, #chars do lookup[chars:sub(i, i)] = i - 1 end lookup["="] = 0 local result = {} local buffer = 0 local bits = 0 for i = 1, #data do local char = data:sub(i, i) local value = lookup[char] if value == nil then return nil end if char == "=" then break end buffer = (buffer * 64) + value bits = bits + 6 if bits >= 8 then bits = bits - 8 result[#result + 1] = string.char(rshift(buffer, bits)) buffer = band(buffer, (lshift(1, bits)) - 1) end end return table.concat(result) end ---Pure Lua SHA-1 implementation ---@param data string The data to hash ---@return string|nil hash The SHA-1 hash in binary format, or nil on error function M.sha1(data) if type(data) ~= "string" then return nil end -- Validate input data is reasonable size (DOS protection) if #data > 10 * 1024 * 1024 then -- 10MB limit return nil end local h0 = 0x67452301 local h1 = 0xEFCDAB89 local h2 = 0x98BADCFE local h3 = 0x10325476 local h4 = 0xC3D2E1F0 local msg = data local msg_len = #msg local bit_len = msg_len * 8 msg = msg .. string.char(0x80) -- Append 0 <= k < 512 bits '0', where the resulting message length -- (in bits) is congruent to 448 (mod 512) while (#msg % 64) ~= 56 do msg = msg .. string.char(0x00) end -- Append length as 64-bit big-endian integer for i = 7, 0, -1 do msg = msg .. string.char(band(rshift(bit_len, i * 8), 0xFF)) end for chunk_start = 1, #msg, 64 do local w = {} -- Break chunk into sixteen 32-bit big-endian words for i = 0, 15 do local offset = chunk_start + i * 4 w[i] = bor( bor(bor(lshift(msg:byte(offset), 24), lshift(msg:byte(offset + 1), 16)), lshift(msg:byte(offset + 2), 8)), msg:byte(offset + 3) ) end -- Extend the sixteen 32-bit words into eighty 32-bit words for i = 16, 79 do w[i] = rotleft(bxor(bxor(bxor(w[i - 3], w[i - 8]), w[i - 14]), w[i - 16]), 1) end local a, b, c, d, e = h0, h1, h2, h3, h4 for i = 0, 79 do local f, k if i <= 19 then f = bor(band(b, c), band(bnot(b), d)) k = 0x5A827999 elseif i <= 39 then f = bxor(bxor(b, c), d) k = 0x6ED9EBA1 elseif i <= 59 then f = bor(bor(band(b, c), band(b, d)), band(c, d)) k = 0x8F1BBCDC else f = bxor(bxor(b, c), d) k = 0xCA62C1D6 end local temp = add32(add32(add32(add32(rotleft(a, 5), f), e), k), w[i]) e = d d = c c = rotleft(b, 30) b = a a = temp end h0 = add32(h0, a) h1 = add32(h1, b) h2 = add32(h2, c) h3 = add32(h3, d) h4 = add32(h4, e) end -- Produce the final hash value as a 160-bit (20-byte) binary string local result = "" for _, h in ipairs({ h0, h1, h2, h3, h4 }) do result = result .. string.char(band(rshift(h, 24), 0xFF), band(rshift(h, 16), 0xFF), band(rshift(h, 8), 0xFF), band(h, 0xFF)) end return result end ---Generate WebSocket accept key from client key ---@param client_key string The client's WebSocket-Key header value ---@return string|nil accept_key The WebSocket accept key, or nil on error function M.generate_accept_key(client_key) local magic_string = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" -- As per RFC 6455, the server concatenates the Sec-WebSocket-Key header value -- with a magic string, SHA1s the result, and then Base64 encodes it. local combined = client_key .. magic_string local hash = M.sha1(combined) if not hash then return nil end return M.base64_encode(hash) end ---Parse HTTP headers from request string ---@param request string The HTTP request string ---@return table headers Table of header name -> value pairs function M.parse_http_headers(request) local headers = {} local lines = {} for line in request:gmatch("[^\r\n]+") do table.insert(lines, line) end for i = 2, #lines do local line = lines[i] local name, value = line:match("^([^:]+):%s*(.+)$") if name and value then headers[name:lower()] = value end end return headers end ---Check if a string contains valid UTF-8 ---@param str string The string to check ---@return boolean valid True if the string is valid UTF-8 function M.is_valid_utf8(str) local i = 1 while i <= #str do local byte = str:byte(i) local char_len = 1 if byte >= 0x80 then if byte >= 0xF0 then char_len = 4 elseif byte >= 0xE0 then char_len = 3 elseif byte >= 0xC0 then char_len = 2 else return false end for j = 1, char_len - 1 do if i + j > #str then return false end local cont_byte = str:byte(i + j) if cont_byte < 0x80 or cont_byte >= 0xC0 then return false end end end i = i + char_len end return true end ---Convert a 16-bit number to big-endian bytes ---@param num number The number to convert ---@return string bytes The big-endian byte representation function M.uint16_to_bytes(num) return string.char(math.floor(num / 256), num % 256) end ---Convert a 64-bit number to big-endian bytes ---@param num number The number to convert ---@return string bytes The big-endian byte representation function M.uint64_to_bytes(num) local bytes = {} for i = 8, 1, -1 do bytes[i] = num % 256 num = math.floor(num / 256) end return string.char(unpack(bytes)) end ---Convert big-endian bytes to a 16-bit number ---@param bytes string The byte string (2 bytes) ---@return number num The converted number function M.bytes_to_uint16(bytes) if #bytes < 2 then return 0 end return bytes:byte(1) * 256 + bytes:byte(2) end ---Convert big-endian bytes to a 64-bit number ---@param bytes string The byte string (8 bytes) ---@return number num The converted number function M.bytes_to_uint64(bytes) if #bytes < 8 then return 0 end local num = 0 for i = 1, 8 do num = num * 256 + bytes:byte(i) end return num end ---XOR lookup table for faster operations local xor_table = {} for i = 0, 255 do xor_table[i] = {} for j = 0, 255 do local result = 0 local a, b = i, j local bit_val = 1 while a > 0 or b > 0 do local a_bit = a % 2 local b_bit = b % 2 if a_bit ~= b_bit then result = result + bit_val end a = math.floor(a / 2) b = math.floor(b / 2) bit_val = bit_val * 2 end xor_table[i][j] = result end end ---Apply XOR mask to payload data ---@param data string The data to mask/unmask ---@param mask string The 4-byte mask ---@return string masked The masked/unmasked data function M.apply_mask(data, mask) local result = {} local mask_bytes = { mask:byte(1, 4) } for i = 1, #data do local mask_idx = ((i - 1) % 4) + 1 local data_byte = data:byte(i) result[i] = string.char(xor_table[data_byte][mask_bytes[mask_idx]]) end return table.concat(result) end ---Shuffle an array in place using Fisher-Yates algorithm ---@param tbl table The array to shuffle function M.shuffle_array(tbl) math.randomseed(os.time()) for i = #tbl, 2, -1 do local j = math.random(i) tbl[i], tbl[j] = tbl[j], tbl[i] end end return M ================================================ FILE: lua/claudecode/terminal/native.lua ================================================ ---Native Neovim terminal provider for Claude Code. ---@module 'claudecode.terminal.native' local M = {} local logger = require("claudecode.logger") local utils = require("claudecode.utils") local bufnr = nil local winid = nil local jobid = nil local tip_shown = false ---@type TerminalConfig local config = require("claudecode.terminal").defaults local function cleanup_state() bufnr = nil winid = nil jobid = nil end local function is_valid() -- First check if we have a valid buffer if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then cleanup_state() return false end -- If buffer is valid but window is invalid, try to find a window displaying this buffer if not winid or not vim.api.nvim_win_is_valid(winid) then -- Search all windows for our terminal buffer local windows = vim.api.nvim_list_wins() for _, win in ipairs(windows) do if vim.api.nvim_win_get_buf(win) == bufnr then -- Found a window displaying our terminal buffer, update the tracked window ID winid = win logger.debug("terminal", "Recovered terminal window ID:", win) return true end end -- Buffer exists but no window displays it - this is normal for hidden terminals return true -- Buffer is valid even though not visible end -- Both buffer and window are valid return true end local function open_terminal(cmd_string, env_table, effective_config, focus) focus = utils.normalize_focus(focus) if is_valid() then -- Should not happen if called correctly, but as a safeguard if focus then -- Focus existing terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) vim.cmd("startinsert") end -- If focus=false, preserve user context by staying in current window return true end local original_win = vim.api.nvim_get_current_win() local width = math.floor(vim.o.columns * effective_config.split_width_percentage) local full_height = vim.o.lines local placement_modifier if effective_config.split_side == "left" then placement_modifier = "topleft " else placement_modifier = "botright " end vim.cmd(placement_modifier .. width .. "vsplit") local new_winid = vim.api.nvim_get_current_win() vim.api.nvim_win_set_height(new_winid, full_height) vim.api.nvim_win_call(new_winid, function() vim.cmd("enew") end) local term_cmd_arg if cmd_string:find(" ", 1, true) then term_cmd_arg = vim.split(cmd_string, " ", { plain = true, trimempty = false }) else term_cmd_arg = { cmd_string } end jobid = vim.fn.termopen(term_cmd_arg, { env = env_table, on_exit = function(job_id, _, _) vim.schedule(function() if job_id == jobid then logger.debug("terminal", "Terminal process exited, cleaning up") -- Ensure we are operating on the correct window and buffer before closing local current_winid_for_job = winid local current_bufnr_for_job = bufnr cleanup_state() -- Clear our managed state first if not effective_config.auto_close then return end if current_winid_for_job and vim.api.nvim_win_is_valid(current_winid_for_job) then if current_bufnr_for_job and vim.api.nvim_buf_is_valid(current_bufnr_for_job) then -- Optional: Check if the window still holds the same terminal buffer if vim.api.nvim_win_get_buf(current_winid_for_job) == current_bufnr_for_job then vim.api.nvim_win_close(current_winid_for_job, true) end else -- Buffer is invalid, but window might still be there (e.g. if user changed buffer in term window) -- Still try to close the window we tracked. vim.api.nvim_win_close(current_winid_for_job, true) end end end end) end, }) if not jobid or jobid == 0 then vim.notify("Failed to open native terminal.", vim.log.levels.ERROR) vim.api.nvim_win_close(new_winid, true) vim.api.nvim_set_current_win(original_win) cleanup_state() return false end winid = new_winid bufnr = vim.api.nvim_get_current_buf() vim.bo[bufnr].bufhidden = "hide" -- buftype=terminal is set by termopen if focus then -- Focus the terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) vim.cmd("startinsert") else -- Preserve user context: return to the window they were in before terminal creation vim.api.nvim_set_current_win(original_win) end if config.show_native_term_exit_tip and not tip_shown then vim.notify("Native terminal opened. Press Ctrl-\\ Ctrl-N to return to Normal mode.", vim.log.levels.INFO) tip_shown = true end return true end local function close_terminal() if is_valid() then -- Closing the window should trigger on_exit of the job if the process is still running, -- which then calls cleanup_state. -- If the job already exited, on_exit would have cleaned up. -- This direct close is for user-initiated close. vim.api.nvim_win_close(winid, true) cleanup_state() -- Cleanup after explicit close end end local function focus_terminal() if is_valid() then vim.api.nvim_set_current_win(winid) vim.cmd("startinsert") end end local function is_terminal_visible() -- Check if our terminal buffer exists and is displayed in any window if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return false end local windows = vim.api.nvim_list_wins() for _, win in ipairs(windows) do if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then -- Update our tracked window ID if we find the buffer in a different window winid = win return true end end -- Buffer exists but no window displays it winid = nil return false end local function hide_terminal() -- Hide the terminal window but keep the buffer and job alive if bufnr and vim.api.nvim_buf_is_valid(bufnr) and winid and vim.api.nvim_win_is_valid(winid) then -- Close the window - this preserves the buffer and job vim.api.nvim_win_close(winid, false) winid = nil -- Clear window reference logger.debug("terminal", "Terminal window hidden, process preserved") end end local function show_hidden_terminal(effective_config, focus) -- Show an existing hidden terminal buffer in a new window if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return false end -- Check if it's already visible if is_terminal_visible() then if focus then focus_terminal() end return true end local original_win = vim.api.nvim_get_current_win() -- Create a new window for the existing buffer local width = math.floor(vim.o.columns * effective_config.split_width_percentage) local full_height = vim.o.lines local placement_modifier if effective_config.split_side == "left" then placement_modifier = "topleft " else placement_modifier = "botright " end vim.cmd(placement_modifier .. width .. "vsplit") local new_winid = vim.api.nvim_get_current_win() vim.api.nvim_win_set_height(new_winid, full_height) -- Set the existing buffer in the new window vim.api.nvim_win_set_buf(new_winid, bufnr) winid = new_winid if focus then -- Focus the terminal: switch to terminal window and enter insert mode vim.api.nvim_set_current_win(winid) vim.cmd("startinsert") else -- Preserve user context: return to the window they were in before showing terminal vim.api.nvim_set_current_win(original_win) end logger.debug("terminal", "Showed hidden terminal in new window") return true end local function find_existing_claude_terminal() local buffers = vim.api.nvim_list_bufs() for _, buf in ipairs(buffers) do if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_get_option(buf, "buftype") == "terminal" then -- Check if this is a Claude Code terminal by examining the buffer name or terminal job local buf_name = vim.api.nvim_buf_get_name(buf) -- Terminal buffers often have names like "term://..." that include the command if buf_name:match("claude") then -- Additional check: see if there's a window displaying this buffer local windows = vim.api.nvim_list_wins() for _, win in ipairs(windows) do if vim.api.nvim_win_get_buf(win) == buf then logger.debug("terminal", "Found existing Claude terminal in buffer", buf, "window", win) return buf, win end end end end end return nil, nil end ---Setup the terminal module ---@param term_config TerminalConfig function M.setup(term_config) config = term_config end --- @param cmd_string string --- @param env_table table --- @param effective_config table --- @param focus boolean|nil function M.open(cmd_string, env_table, effective_config, focus) focus = utils.normalize_focus(focus) if is_valid() then -- Check if terminal exists but is hidden (no window) if not winid or not vim.api.nvim_win_is_valid(winid) then -- Terminal is hidden, show it by calling show_hidden_terminal show_hidden_terminal(effective_config, focus) else -- Terminal is already visible if focus then focus_terminal() end end else -- Check if there's an existing Claude terminal we lost track of local existing_buf, existing_win = find_existing_claude_terminal() if existing_buf and existing_win then -- Recover the existing terminal bufnr = existing_buf winid = existing_win -- Note: We can't recover the job ID easily, but it's less critical logger.debug("terminal", "Recovered existing Claude terminal") if focus then focus_terminal() -- Focus recovered terminal end -- If focus=false, preserve user context by staying in current window else if not open_terminal(cmd_string, env_table, effective_config, focus) then vim.notify("Failed to open Claude terminal using native fallback.", vim.log.levels.ERROR) end end end end function M.close() close_terminal() end ---Simple toggle: always show/hide terminal regardless of focus ---@param cmd_string string ---@param env_table table ---@param effective_config TerminalConfig function M.simple_toggle(cmd_string, env_table, effective_config) -- Check if we have a valid terminal buffer (process running) local has_buffer = bufnr and vim.api.nvim_buf_is_valid(bufnr) local is_visible = has_buffer and is_terminal_visible() if is_visible then -- Terminal is visible, hide it (but keep process running) hide_terminal() else -- Terminal is not visible if has_buffer then -- Terminal process exists but is hidden, show it if show_hidden_terminal(effective_config, true) then logger.debug("terminal", "Showing hidden terminal") else logger.error("terminal", "Failed to show hidden terminal") end else -- No terminal process exists, check if there's an existing one we lost track of local existing_buf, existing_win = find_existing_claude_terminal() if existing_buf and existing_win then -- Recover the existing terminal bufnr = existing_buf winid = existing_win logger.debug("terminal", "Recovered existing Claude terminal") focus_terminal() else -- No existing terminal found, create a new one if not open_terminal(cmd_string, env_table, effective_config) then vim.notify("Failed to open Claude terminal using native fallback (simple_toggle).", vim.log.levels.ERROR) end end end end end ---Smart focus toggle: switches to terminal if not focused, hides if currently focused ---@param cmd_string string ---@param env_table table ---@param effective_config TerminalConfig function M.focus_toggle(cmd_string, env_table, effective_config) -- Check if we have a valid terminal buffer (process running) local has_buffer = bufnr and vim.api.nvim_buf_is_valid(bufnr) local is_visible = has_buffer and is_terminal_visible() if has_buffer then -- Terminal process exists if is_visible then -- Terminal is visible - check if we're currently in it local current_win_id = vim.api.nvim_get_current_win() if winid == current_win_id then -- We're in the terminal window, hide it (but keep process running) hide_terminal() else -- Terminal is visible but we're not in it, focus it focus_terminal() end else -- Terminal process exists but is hidden, show it if show_hidden_terminal(effective_config, true) then logger.debug("terminal", "Showing hidden terminal") else logger.error("terminal", "Failed to show hidden terminal") end end else -- No terminal process exists, check if there's an existing one we lost track of local existing_buf, existing_win = find_existing_claude_terminal() if existing_buf and existing_win then -- Recover the existing terminal bufnr = existing_buf winid = existing_win logger.debug("terminal", "Recovered existing Claude terminal") -- Check if we're currently in this recovered terminal local current_win_id = vim.api.nvim_get_current_win() if existing_win == current_win_id then -- We're in the recovered terminal, hide it hide_terminal() else -- Focus the recovered terminal focus_terminal() end else -- No existing terminal found, create a new one if not open_terminal(cmd_string, env_table, effective_config) then vim.notify("Failed to open Claude terminal using native fallback (focus_toggle).", vim.log.levels.ERROR) end end end end --- Legacy toggle function for backward compatibility (defaults to simple_toggle) --- @param cmd_string string --- @param env_table table --- @param effective_config TerminalConfig function M.toggle(cmd_string, env_table, effective_config) M.simple_toggle(cmd_string, env_table, effective_config) end --- @return number|nil function M.get_active_bufnr() if is_valid() then return bufnr end return nil end --- @return boolean function M.is_available() return true -- Native provider is always available end --- @type TerminalProvider return M ================================================ FILE: lua/claudecode/terminal/snacks.lua ================================================ ---Snacks.nvim terminal provider for Claude Code. ---@module 'claudecode.terminal.snacks' local M = {} local snacks_available, Snacks = pcall(require, "snacks") local utils = require("claudecode.utils") local terminal = nil --- @return boolean local function is_available() return snacks_available and Snacks and Snacks.terminal ~= nil end ---Setup event handlers for terminal instance ---@param term_instance table The Snacks terminal instance ---@param config table Configuration options local function setup_terminal_events(term_instance, config) local logger = require("claudecode.logger") -- Handle command completion/exit - only if auto_close is enabled if config.auto_close then term_instance:on("TermClose", function() if vim.v.event.status ~= 0 then logger.error("terminal", "Claude exited with code " .. vim.v.event.status .. ".\nCheck for any errors.") end -- Clean up terminal = nil vim.schedule(function() term_instance:close({ buf = true }) vim.cmd.checktime() end) end, { buf = true }) end -- Handle buffer deletion term_instance:on("BufWipeout", function() logger.debug("terminal", "Terminal buffer wiped") terminal = nil end, { buf = true }) end ---Builds Snacks terminal options with focus control ---@param config TerminalConfig Terminal configuration ---@param env_table table Environment variables to set for the terminal process ---@param focus boolean|nil Whether to focus the terminal when opened (defaults to true) ---@return table options Snacks terminal options with start_insert/auto_insert controlled by focus parameter local function build_opts(config, env_table, focus) focus = utils.normalize_focus(focus) return { env = env_table, start_insert = focus, auto_insert = focus, auto_close = false, win = vim.tbl_deep_extend("force", { position = config.split_side, width = config.split_width_percentage, height = 0, relative = "editor", }, config.snacks_win_opts or {}), } end function M.setup() -- No specific setup needed for Snacks provider end ---Open a terminal using Snacks.nvim ---@param cmd_string string ---@param env_table table ---@param config TerminalConfig ---@param focus boolean? function M.open(cmd_string, env_table, config, focus) if not is_available() then vim.notify("Snacks.nvim terminal provider selected but Snacks.terminal not available.", vim.log.levels.ERROR) return end focus = utils.normalize_focus(focus) if terminal and terminal:buf_valid() then -- Check if terminal exists but is hidden (no window) if not terminal.win or not vim.api.nvim_win_is_valid(terminal.win) then -- Terminal is hidden, show it using snacks toggle terminal:toggle() if focus then terminal:focus() local term_buf_id = terminal.buf if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then vim.api.nvim_win_call(terminal.win, function() vim.cmd("startinsert") end) end end end else -- Terminal is already visible if focus then terminal:focus() local term_buf_id = terminal.buf if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then -- Check if window is valid before calling nvim_win_call if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then vim.api.nvim_win_call(terminal.win, function() vim.cmd("startinsert") end) end end end end return end local opts = build_opts(config, env_table, focus) local term_instance = Snacks.terminal.open(cmd_string, opts) if term_instance and term_instance:buf_valid() then setup_terminal_events(term_instance, config) terminal = term_instance else terminal = nil local logger = require("claudecode.logger") local error_details = {} if not term_instance then table.insert(error_details, "Snacks.terminal.open() returned nil") elseif not term_instance:buf_valid() then table.insert(error_details, "terminal instance is invalid") if term_instance.buf and not vim.api.nvim_buf_is_valid(term_instance.buf) then table.insert(error_details, "buffer is invalid") end if term_instance.win and not vim.api.nvim_win_is_valid(term_instance.win) then table.insert(error_details, "window is invalid") end end local context = string.format("cmd='%s', opts=%s", cmd_string, vim.inspect(opts)) local error_msg = string.format( "Failed to open Claude terminal using Snacks. Details: %s. Context: %s", table.concat(error_details, ", "), context ) vim.notify(error_msg, vim.log.levels.ERROR) logger.debug("terminal", error_msg) end end ---Close the terminal function M.close() if not is_available() then return end if terminal and terminal:buf_valid() then terminal:close() end end ---Simple toggle: always show/hide terminal regardless of focus ---@param cmd_string string ---@param env_table table ---@param config table function M.simple_toggle(cmd_string, env_table, config) if not is_available() then vim.notify("Snacks.nvim terminal provider selected but Snacks.terminal not available.", vim.log.levels.ERROR) return end local logger = require("claudecode.logger") -- Check if terminal exists and is visible if terminal and terminal:buf_valid() and terminal:win_valid() then -- Terminal is visible, hide it logger.debug("terminal", "Simple toggle: hiding visible terminal") terminal:toggle() elseif terminal and terminal:buf_valid() and not terminal:win_valid() then -- Terminal exists but not visible, show it logger.debug("terminal", "Simple toggle: showing hidden terminal") terminal:toggle() else -- No terminal exists, create new one logger.debug("terminal", "Simple toggle: creating new terminal") M.open(cmd_string, env_table, config) end end ---Smart focus toggle: switches to terminal if not focused, hides if currently focused ---@param cmd_string string ---@param env_table table ---@param config table function M.focus_toggle(cmd_string, env_table, config) if not is_available() then vim.notify("Snacks.nvim terminal provider selected but Snacks.terminal not available.", vim.log.levels.ERROR) return end local logger = require("claudecode.logger") -- Terminal exists, is valid, but not visible if terminal and terminal:buf_valid() and not terminal:win_valid() then logger.debug("terminal", "Focus toggle: showing hidden terminal") terminal:toggle() -- Terminal exists, is valid, and is visible elseif terminal and terminal:buf_valid() and terminal:win_valid() then local claude_term_neovim_win_id = terminal.win local current_neovim_win_id = vim.api.nvim_get_current_win() -- you're IN it if claude_term_neovim_win_id == current_neovim_win_id then logger.debug("terminal", "Focus toggle: hiding terminal (currently focused)") terminal:toggle() -- you're NOT in it else logger.debug("terminal", "Focus toggle: focusing terminal") vim.api.nvim_set_current_win(claude_term_neovim_win_id) if terminal.buf and vim.api.nvim_buf_is_valid(terminal.buf) then if vim.api.nvim_buf_get_option(terminal.buf, "buftype") == "terminal" then vim.api.nvim_win_call(claude_term_neovim_win_id, function() vim.cmd("startinsert") end) end end end -- No terminal exists else logger.debug("terminal", "Focus toggle: creating new terminal") M.open(cmd_string, env_table, config) end end ---Legacy toggle function for backward compatibility (defaults to simple_toggle) ---@param cmd_string string ---@param env_table table ---@param config table function M.toggle(cmd_string, env_table, config) M.simple_toggle(cmd_string, env_table, config) end ---Get the active terminal buffer number ---@return number? function M.get_active_bufnr() if terminal and terminal:buf_valid() and terminal.buf then if vim.api.nvim_buf_is_valid(terminal.buf) then return terminal.buf end end return nil end ---Is the terminal provider available? ---@return boolean function M.is_available() return is_available() end ---For testing purposes ---@return table? terminal The terminal instance, or nil function M._get_terminal_for_test() return terminal end ---@type TerminalProvider return M ================================================ FILE: lua/claudecode/tools/check_document_dirty.lua ================================================ ---Tool implementation for checking if a document is dirty. local schema = { description = "Check if a document has unsaved changes (is dirty)", inputSchema = { type = "object", properties = { filePath = { type = "string", description = "Path to the file to check", }, }, required = { "filePath" }, additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Handles the checkDocumentDirty tool invocation. ---Checks if the specified file (buffer) has unsaved changes. ---@param params table The input parameters for the tool ---@return table MCP-compliant response with dirty status local function handler(params) if not params.filePath then error({ code = -32602, message = "Invalid params", data = "Missing filePath parameter" }) end local bufnr = vim.fn.bufnr(params.filePath) if bufnr == -1 then -- Return success: false when document not open, matching VS Code behavior return { content = { { type = "text", text = vim.json.encode({ success = false, message = "Document not open: " .. params.filePath, }, { indent = 2 }), }, }, } end local is_dirty = vim.api.nvim_buf_get_option(bufnr, "modified") local is_untitled = vim.api.nvim_buf_get_name(bufnr) == "" -- Return MCP-compliant format with JSON-stringified result return { content = { { type = "text", text = vim.json.encode({ success = true, filePath = params.filePath, isDirty = is_dirty, isUntitled = is_untitled, }, { indent = 2 }), }, }, } end return { name = "checkDocumentDirty", schema = schema, handler = handler, } ================================================ FILE: lua/claudecode/tools/close_all_diff_tabs.lua ================================================ --- Tool implementation for closing all diff tabs. local schema = { description = "Close all diff tabs in the editor", inputSchema = { type = "object", additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Handles the closeAllDiffTabs tool invocation. ---Closes all diff tabs/windows in the editor. ---@return table response MCP-compliant response with content array indicating number of closed tabs. local function handler(params) local closed_count = 0 -- Get all windows local windows = vim.api.nvim_list_wins() local windows_to_close = {} -- Use set to avoid duplicates for _, win in ipairs(windows) do local buf = vim.api.nvim_win_get_buf(win) local buftype = vim.api.nvim_buf_get_option(buf, "buftype") local diff_mode = vim.api.nvim_win_get_option(win, "diff") local should_close = false -- Check if this is a diff window if diff_mode then should_close = true end -- Also check for diff-related buffer names or types local buf_name = vim.api.nvim_buf_get_name(buf) if buf_name:match("%.diff$") or buf_name:match("diff://") then should_close = true end -- Check for special diff buffer types if buftype == "nofile" and buf_name:match("^fugitive://") then should_close = true end -- Add to close set only once (prevents duplicates) if should_close then windows_to_close[win] = true end end -- Close the identified diff windows for win, _ in pairs(windows_to_close) do if vim.api.nvim_win_is_valid(win) then local success = pcall(vim.api.nvim_win_close, win, false) if success then closed_count = closed_count + 1 end end end -- Also check for buffers that might be diff-related but not currently in windows local buffers = vim.api.nvim_list_bufs() for _, buf in ipairs(buffers) do if vim.api.nvim_buf_is_loaded(buf) then local buf_name = vim.api.nvim_buf_get_name(buf) local buftype = vim.api.nvim_buf_get_option(buf, "buftype") -- Check for diff-related buffers if buf_name:match("%.diff$") or buf_name:match("diff://") or (buftype == "nofile" and buf_name:match("^fugitive://")) then -- Delete the buffer if it's not in any window local buf_windows = vim.fn.win_findbuf(buf) if #buf_windows == 0 then local success = pcall(vim.api.nvim_buf_delete, buf, { force = true }) if success then closed_count = closed_count + 1 end end end end end -- Return MCP-compliant format matching VS Code extension return { content = { { type = "text", text = "CLOSED_" .. closed_count .. "_DIFF_TABS", }, }, } end return { name = "closeAllDiffTabs", schema = schema, handler = handler, } ================================================ FILE: lua/claudecode/tools/close_tab.lua ================================================ --- Tool implementation for closing a buffer by its name. -- Note: Schema defined but not used since this tool is internal -- local schema = { -- description = "Close a tab/buffer by its tab name", -- inputSchema = { -- type = "object", -- properties = { -- tab_name = { -- type = "string", -- description = "Name of the tab to close", -- }, -- }, -- required = { "tab_name" }, -- additionalProperties = false, -- ["$schema"] = "http://json-schema.org/draft-07/schema#", -- }, -- } ---Handles the close_tab tool invocation. ---Closes a tab/buffer by its tab name. ---@param params {tab_name: string} The input parameters for the tool ---@return table success A result message indicating success local function handler(params) local log_module_ok, log = pcall(require, "claudecode.logger") if not log_module_ok then return { code = -32603, -- Internal error message = "Internal error", data = "Failed to load logger module", } end log.debug("close_tab handler called with params: " .. vim.inspect(params)) if not params.tab_name then log.error("Missing required parameter: tab_name") return { code = -32602, -- Invalid params message = "Invalid params", data = "Missing required parameter: tab_name", } end -- Extract the actual file name from the tab name -- Tab name format: "✻ [Claude Code] README.md (e18e1e) ⧉" -- We need to extract "README.md" or the full path local tab_name = params.tab_name log.debug("Attempting to close tab: " .. tab_name) -- Check if this is a diff tab (contains ✻ and ⧉ markers) if tab_name:match("✻") and tab_name:match("⧉") then log.debug("Detected diff tab - closing diff view") -- Try to close the diff local diff_module_ok, diff = pcall(require, "claudecode.diff") if diff_module_ok and diff.close_diff_by_tab_name then local closed = diff.close_diff_by_tab_name(tab_name) if closed then log.debug("Successfully closed diff for tab: " .. tab_name) return { content = { { type = "text", text = "TAB_CLOSED", }, }, } else log.debug("Diff not found for tab: " .. tab_name) return { content = { { type = "text", text = "TAB_CLOSED", }, }, } end else log.error("Failed to load diff module or close_diff_by_tab_name not available") return { content = { { type = "text", text = "TAB_CLOSED", }, }, } end end -- Try to find buffer by the tab name first local bufnr = vim.fn.bufnr(tab_name) if bufnr == -1 then -- If not found, try to extract filename from the tab name -- Look for pattern like "filename.ext" in the tab name local filename = tab_name:match("([%w%.%-_]+%.[%w]+)") if filename then log.debug("Extracted filename from tab name: " .. filename) -- Try to find buffer by filename for _, buf in ipairs(vim.api.nvim_list_bufs()) do local buf_name = vim.api.nvim_buf_get_name(buf) if buf_name:match(filename .. "$") then bufnr = buf log.debug("Found buffer by filename match: " .. buf_name) break end end end end if bufnr == -1 then -- If buffer not found, the tab might already be closed - treat as success log.debug("Buffer not found for tab (already closed?): " .. tab_name) return { content = { { type = "text", text = "TAB_CLOSED", }, }, } end local success, err = pcall(vim.api.nvim_buf_delete, bufnr, { force = false }) if not success then log.error("Failed to close buffer: " .. tostring(err)) return { code = -32000, message = "Buffer operation error", data = "Failed to close buffer for tab " .. tab_name .. ": " .. tostring(err), } end log.info("Successfully closed tab: " .. tab_name) -- Return MCP-compliant format matching VS Code extension return { content = { { type = "text", text = "TAB_CLOSED", }, }, } end return { name = "close_tab", schema = nil, -- Internal tool - must remain as requested by user handler = handler, } ================================================ FILE: lua/claudecode/tools/get_current_selection.lua ================================================ --- Tool implementation for getting the current selection. local schema = { description = "Get the current text selection in the editor", inputSchema = { type = "object", additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Helper function to safely encode data as JSON with error handling. ---@param data table The data to encode as JSON ---@param error_context string A description of what failed for error messages ---@return string The JSON-encoded string local function safe_json_encode(data, error_context) local ok, encoded = pcall(vim.json.encode, data, { indent = 2 }) if not ok then error({ code = -32000, message = "Internal server error", data = "Failed to encode " .. error_context .. ": " .. tostring(encoded), }) end return encoded end ---Handles the getCurrentSelection tool invocation. ---Gets the current text selection in the editor. ---@return table response MCP-compliant response with selection data. local function handler(params) local selection_module_ok, selection_module = pcall(require, "claudecode.selection") if not selection_module_ok then error({ code = -32000, message = "Internal server error", data = "Failed to load selection module" }) end local selection = selection_module.get_latest_selection() if not selection then -- Check if there's an active editor/buffer local current_buf = vim.api.nvim_get_current_buf() local buf_name = vim.api.nvim_buf_get_name(current_buf) if not buf_name or buf_name == "" then -- No active editor case - match VS Code format local no_editor_response = { success = false, message = "No active editor found", } return { content = { { type = "text", text = safe_json_encode(no_editor_response, "no editor response"), }, }, } end -- Valid buffer but no selection - return cursor position with success field local empty_selection = { success = true, text = "", filePath = buf_name, fileUrl = "file://" .. buf_name, selection = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 }, isEmpty = true, }, } -- Return MCP-compliant format with JSON-stringified empty selection return { content = { { type = "text", text = safe_json_encode(empty_selection, "empty selection"), }, }, } end -- Add success field to existing selection data local selection_with_success = vim.tbl_extend("force", selection, { success = true }) -- Return MCP-compliant format with JSON-stringified selection data return { content = { { type = "text", text = safe_json_encode(selection_with_success, "selection"), }, }, } end return { name = "getCurrentSelection", schema = schema, handler = handler, } ================================================ FILE: lua/claudecode/tools/get_diagnostics.lua ================================================ --- Tool implementation for getting diagnostics. -- NOTE: Its important we don't tip off Claude that we're dealing with Neovim LSP diagnostics because it may adjust -- line and col numbers by 1 on its own (since it knows nvim LSP diagnostics are 0-indexed). By calling these -- "editor diagnostics" and converting to 1-indexed ourselves we (hopefully) avoid incorrect line and column numbers -- in Claude's responses. local schema = { description = "Get language diagnostics (errors, warnings) from the editor", inputSchema = { type = "object", properties = { uri = { type = "string", description = "Optional file URI to get diagnostics for. If not provided, gets diagnostics for all open files.", }, }, additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Handles the getDiagnostics tool invocation. ---Retrieves diagnostics from Neovim's diagnostic system. ---@param params table The input parameters for the tool ---@return table diagnostics MCP-compliant response with diagnostics data local function handler(params) if not vim.lsp or not vim.diagnostic or not vim.diagnostic.get then -- Returning an empty list or a specific status could be an alternative. -- For now, let's align with the error pattern for consistency if the feature is unavailable. error({ code = -32000, message = "Feature unavailable", data = "Diagnostics not available in this editor version/configuration.", }) end local logger = require("claudecode.logger") logger.debug("getDiagnostics handler called with params: " .. vim.inspect(params)) -- Extract the uri parameter local diagnostics if not params.uri then -- Get diagnostics for all buffers logger.debug("Getting diagnostics for all open buffers") diagnostics = vim.diagnostic.get(nil) else local uri = params.uri -- Strips the file:// scheme local filepath = vim.uri_to_fname(uri) -- Get buffer number for the specific file local bufnr = vim.fn.bufnr(filepath) if bufnr == -1 then -- File is not open in any buffer, throw an error logger.debug("File buffer must be open to get diagnostics: " .. filepath) error({ code = -32001, message = "File not open", data = "File must be open to retrieve diagnostics: " .. filepath, }) else -- Get diagnostics for the specific buffer logger.debug("Getting diagnostics for bufnr: " .. bufnr) diagnostics = vim.diagnostic.get(bufnr) end end local formatted_diagnostics = {} for _, diagnostic in ipairs(diagnostics) do local file_path = vim.api.nvim_buf_get_name(diagnostic.bufnr) -- Ensure we only include diagnostics with valid file paths if file_path and file_path ~= "" then table.insert(formatted_diagnostics, { type = "text", -- json encode this text = vim.json.encode({ -- Use the file path and diagnostic information filePath = file_path, -- Convert line and column to 1-indexed line = diagnostic.lnum + 1, character = diagnostic.col + 1, severity = diagnostic.severity, -- e.g., vim.diagnostic.severity.ERROR message = diagnostic.message, source = diagnostic.source, }), }) end end return { content = formatted_diagnostics, } end return { name = "getDiagnostics", schema = schema, handler = handler, } ================================================ FILE: lua/claudecode/tools/get_latest_selection.lua ================================================ --- Tool implementation for getting the latest text selection. local schema = { description = "Get the most recent text selection (even if not in the active editor)", inputSchema = { type = "object", additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Handles the getLatestSelection tool invocation. ---Gets the most recent text selection, even if not in the current active editor. ---This is different from getCurrentSelection which only gets selection from active editor. ---@return table content MCP-compliant response with content array local function handler(params) local selection_module_ok, selection_module = pcall(require, "claudecode.selection") if not selection_module_ok then error({ code = -32000, message = "Internal server error", data = "Failed to load selection module" }) end local selection = selection_module.get_latest_selection() if not selection then -- Return MCP-compliant format with JSON-stringified result return { content = { { type = "text", text = vim.json.encode({ success = false, message = "No selection available", }, { indent = 2 }), }, }, } end -- Return MCP-compliant format with JSON-stringified selection data return { content = { { type = "text", text = vim.json.encode(selection, { indent = 2 }), }, }, } end return { name = "getLatestSelection", schema = schema, handler = handler, } ================================================ FILE: lua/claudecode/tools/get_open_editors.lua ================================================ --- Tool implementation for getting a list of open editors. local schema = { description = "Get list of currently open files", inputSchema = { type = "object", additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Handles the getOpenEditors tool invocation. ---Gets a list of currently open and listed files in Neovim. ---@return table response MCP-compliant response with editor tabs data local function handler(params) local tabs = {} local buffers = vim.api.nvim_list_bufs() local current_buf = vim.api.nvim_get_current_buf() local current_tabpage = vim.api.nvim_get_current_tabpage() -- Get selection for active editor if available local active_selection = nil local selection_module_ok, selection_module = pcall(require, "claudecode.selection") if selection_module_ok then active_selection = selection_module.get_latest_selection() end for _, bufnr in ipairs(buffers) do -- Only include loaded, listed buffers with a file path if vim.api.nvim_buf_is_loaded(bufnr) and vim.fn.buflisted(bufnr) == 1 then local file_path = vim.api.nvim_buf_get_name(bufnr) if file_path and file_path ~= "" then -- Get the filename for the label local ok_label, label = pcall(vim.fn.fnamemodify, file_path, ":t") if not ok_label then label = file_path -- Fallback to full path end -- Get language ID (filetype) local ok_lang, language_id = pcall(vim.api.nvim_buf_get_option, bufnr, "filetype") if not ok_lang or language_id == nil or language_id == "" then language_id = "plaintext" end -- Get line count local line_count = 0 local ok_lines, lines_result = pcall(vim.api.nvim_buf_line_count, bufnr) if ok_lines then line_count = lines_result end -- Check if untitled (no file path or special buffer) local is_untitled = ( not file_path or file_path == "" or string.match(file_path, "^%s*$") ~= nil or string.match(file_path, "^term://") ~= nil or string.match(file_path, "^%[.*%]$") ~= nil ) -- Get tabpage info for this buffer -- For simplicity, use current tabpage as the "group" for all buffers -- In a more complex implementation, we could track which tabpage last showed each buffer local group_index = current_tabpage - 1 -- 0-based local view_column = current_tabpage -- 1-based local is_group_active = true -- Current tabpage is always active -- Build tab object with all VS Code fields local tab = { uri = "file://" .. file_path, isActive = bufnr == current_buf, isPinned = false, -- Neovim doesn't have pinned tabs isPreview = false, -- Neovim doesn't have preview tabs isDirty = (function() local ok, modified = pcall(vim.api.nvim_buf_get_option, bufnr, "modified") return ok and modified or false end)(), label = label, groupIndex = group_index, viewColumn = view_column, isGroupActive = is_group_active, fileName = file_path, languageId = language_id, lineCount = line_count, isUntitled = is_untitled, } -- Add selection info for active editor if bufnr == current_buf and active_selection and active_selection.selection then tab.selection = { start = active_selection.selection.start, ["end"] = active_selection.selection["end"], isReversed = false, -- Neovim doesn't track reversed selections like VS Code } end table.insert(tabs, tab) end end end -- Return MCP-compliant format with JSON-stringified tabs array matching VS Code format return { content = { { type = "text", text = vim.json.encode({ tabs = tabs }, { indent = 2 }), }, }, } end return { name = "getOpenEditors", schema = schema, handler = handler, } ================================================ FILE: lua/claudecode/tools/get_workspace_folders.lua ================================================ --- Tool implementation for getting workspace folders. local schema = { description = "Get all workspace folders currently open in the IDE", inputSchema = { type = "object", additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Handles the getWorkspaceFolders tool invocation. ---Retrieves workspace folders, currently defaulting to CWD and attempting LSP integration. ---@return table MCP-compliant response with workspace folders data local function handler(params) local cwd = vim.fn.getcwd() -- TODO: Enhance integration with LSP workspace folders if available, -- similar to how it's done in claudecode.lockfile.get_workspace_folders. -- For now, this is a simplified version as per the original tool's direct implementation. local folders = { { name = vim.fn.fnamemodify(cwd, ":t"), uri = "file://" .. cwd, path = cwd, }, } -- A more complete version would replicate the logic from claudecode.lockfile: -- local lsp_folders = get_lsp_workspace_folders_logic_here() -- for _, folder_path in ipairs(lsp_folders) do -- local already_exists = false -- for _, existing_folder in ipairs(folders) do -- if existing_folder.path == folder_path then -- already_exists = true -- break -- end -- end -- if not already_exists then -- table.insert(folders, { -- name = vim.fn.fnamemodify(folder_path, ":t"), -- uri = "file://" .. folder_path, -- path = folder_path, -- }) -- end -- end -- Return MCP-compliant format with JSON-stringified workspace data return { content = { { type = "text", text = vim.json.encode({ success = true, folders = folders, rootPath = cwd, }, { indent = 2 }), }, }, } end return { name = "getWorkspaceFolders", schema = schema, handler = handler, } ================================================ FILE: lua/claudecode/tools/init.lua ================================================ -- Tool implementation for Claude Code Neovim integration local M = {} M.ERROR_CODES = { PARSE_ERROR = -32700, INVALID_REQUEST = -32600, METHOD_NOT_FOUND = -32601, INVALID_PARAMS = -32602, INTERNAL_ERROR = -32000, -- Default for tool execution if not more specific -- Custom / server specific: -32000 to -32099 } M.tools = {} ---Setup the tools module function M.setup(server) M.server = server M.register_all() end ---Get the complete tool list for MCP tools/list handler function M.get_tool_list() local tool_list = {} for name, tool_data in pairs(M.tools) do -- Only include tools that have schemas (are meant to be exposed via MCP) if tool_data.schema then local tool_def = { name = name, description = tool_data.schema.description, inputSchema = tool_data.schema.inputSchema, } table.insert(tool_list, tool_def) end end return tool_list end ---Register all tools function M.register_all() -- Register MCP-exposed tools with schemas M.register(require("claudecode.tools.open_file")) M.register(require("claudecode.tools.get_current_selection")) M.register(require("claudecode.tools.get_open_editors")) M.register(require("claudecode.tools.open_diff")) M.register(require("claudecode.tools.get_latest_selection")) M.register(require("claudecode.tools.close_all_diff_tabs")) M.register(require("claudecode.tools.get_diagnostics")) M.register(require("claudecode.tools.get_workspace_folders")) M.register(require("claudecode.tools.check_document_dirty")) M.register(require("claudecode.tools.save_document")) -- Register internal tools without schemas (not exposed via MCP) M.register(require("claudecode.tools.close_tab")) end ---Register a tool function M.register(tool_module) if not tool_module or not tool_module.name or not tool_module.handler then local name = "unknown" if type(tool_module) == "table" and type(tool_module.name) == "string" then name = tool_module.name elseif type(tool_module) == "string" then -- if require failed, it might be the path string name = tool_module end vim.notify( "Error registering tool: Invalid tool module structure for " .. name, vim.log.levels.ERROR, { title = "ClaudeCode Tool Registration" } ) return end M.tools[tool_module.name] = { handler = tool_module.handler, schema = tool_module.schema, -- Will be nil if not defined in the module requires_coroutine = tool_module.requires_coroutine, -- Will be nil if not defined in the module } end ---Handle an invocation of a tool function M.handle_invoke(client, params) -- client needed for blocking tools local tool_name = params.name local input = params.arguments if not M.tools[tool_name] then return { error = { code = -32601, -- JSON-RPC Method not found message = "Tool not found: " .. tool_name, }, } end local tool_data = M.tools[tool_name] -- Tool handlers are now expected to: -- 1. Raise an error (e.g., error({code=..., message=...}) or error("string")) -- 2. Return (false, "error message string" or {code=..., message=...}) for pcall-style errors -- 3. Return the result directly for success. -- Check if this tool requires coroutine context for blocking behavior local pcall_results if tool_data.requires_coroutine then -- Wrap in coroutine for blocking behavior require("claudecode.logger").debug("tools", "Wrapping " .. tool_name .. " in coroutine for blocking behavior") local co = coroutine.create(function() return tool_data.handler(input) end) require("claudecode.logger").debug("tools", "About to resume coroutine for " .. tool_name) local success, result = coroutine.resume(co) require("claudecode.logger").debug( "tools", "Coroutine resume returned - success:", success, "status:", coroutine.status(co) ) if coroutine.status(co) == "suspended" then require("claudecode.logger").debug("tools", "Coroutine is suspended - tool is blocking, will respond later") -- The coroutine yielded, which means the tool is blocking -- Return a special marker to indicate this is a deferred response return { _deferred = true, coroutine = co, client = client, params = params } end require("claudecode.logger").debug( "tools", "Coroutine completed for " .. tool_name .. ", success: " .. tostring(success) ) pcall_results = { success, result } else pcall_results = { pcall(tool_data.handler, input) } end local pcall_success = pcall_results[1] local handler_return_val1 = pcall_results[2] local handler_return_val2 = pcall_results[3] if not pcall_success then -- Case 1: Handler itself raised a Lua error (e.g. error("foo") or error({...})) -- handler_return_val1 contains the error object/string from the pcall local err_code = M.ERROR_CODES.INTERNAL_ERROR local err_msg = "Tool execution failed via error()" local err_data_payload = tostring(handler_return_val1) if type(handler_return_val1) == "table" and handler_return_val1.code and handler_return_val1.message then err_code = handler_return_val1.code err_msg = handler_return_val1.message err_data_payload = handler_return_val1.data elseif type(handler_return_val1) == "string" then err_msg = handler_return_val1 end return { error = { code = err_code, message = err_msg, data = err_data_payload } } end -- pcall succeeded, now check the handler's actual return values -- Case 2: Handler returned (false, "error message" or {error_obj}) if handler_return_val1 == false then local err_val_from_handler = handler_return_val2 -- This is the actual error string or table local err_code = M.ERROR_CODES.INTERNAL_ERROR local err_msg = "Tool reported an error" local err_data_payload = tostring(err_val_from_handler) if type(err_val_from_handler) == "table" and err_val_from_handler.code and err_val_from_handler.message then err_code = err_val_from_handler.code err_msg = err_val_from_handler.message err_data_payload = err_val_from_handler.data elseif type(err_val_from_handler) == "string" then err_msg = err_val_from_handler end return { error = { code = err_code, message = err_msg, data = err_data_payload } } end -- Case 3: Handler succeeded and returned the result directly -- handler_return_val1 is the actual result return { result = handler_return_val1 } end return M ================================================ FILE: lua/claudecode/tools/open_diff.lua ================================================ --- Tool implementation for opening a diff view. local schema = { description = "Open a diff view comparing old file content with new file content", inputSchema = { type = "object", properties = { old_file_path = { type = "string", description = "Path to the old file to compare", }, new_file_path = { type = "string", description = "Path to the new file to compare", }, new_file_contents = { type = "string", description = "Contents for the new file version", }, tab_name = { type = "string", description = "Name for the diff tab/view", }, }, required = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" }, additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Handles the openDiff tool invocation with MCP compliance. ---Opens a diff view and blocks until user interaction (save/close). ---Returns MCP-compliant response with content array format. ---@param params table The input parameters for the tool ---@return table response MCP-compliant response with content array local function handler(params) -- Validate required parameters local required_params = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" } for _, param_name in ipairs(required_params) do if not params[param_name] then error({ code = -32602, -- Invalid params message = "Invalid params", data = "Missing required parameter: " .. param_name, }) end end -- Ensure we're running in a coroutine context for blocking operation local co, is_main = coroutine.running() if not co or is_main then error({ code = -32000, message = "Internal server error", data = "openDiff must run in coroutine context", }) end local diff_module_ok, diff_module = pcall(require, "claudecode.diff") if not diff_module_ok then error({ code = -32000, message = "Internal server error", data = "Failed to load diff module" }) end -- Use the new blocking diff operation local success, result = pcall( diff_module.open_diff_blocking, params.old_file_path, params.new_file_path, params.new_file_contents, params.tab_name ) if not success then -- Check if this is already a structured error if type(result) == "table" and result.code then error(result) else error({ code = -32000, -- Generic tool error message = "Error opening blocking diff", data = tostring(result), }) end end -- result should already be MCP-compliant with content array format return result end return { name = "openDiff", schema = schema, handler = handler, requires_coroutine = true, -- This tool needs coroutine context for blocking behavior } ================================================ FILE: lua/claudecode/tools/open_file.lua ================================================ --- Tool implementation for opening a file. local schema = { description = "Open a file in the editor and optionally select a range of text", inputSchema = { type = "object", properties = { filePath = { type = "string", description = "Path to the file to open", }, preview = { type = "boolean", description = "Whether to open the file in preview mode", default = false, }, startLine = { type = "integer", description = "Optional: Line number to start selection", }, endLine = { type = "integer", description = "Optional: Line number to end selection", }, startText = { type = "string", description = "Text pattern to find the start of the selection range. Selects from the beginning of this match.", }, endText = { type = "string", description = "Text pattern to find the end of the selection range. Selects up to the end of this match. If not provided, only the startText match will be selected.", }, selectToEndOfLine = { type = "boolean", description = "If true, selection will extend to the end of the line containing the endText match.", default = false, }, makeFrontmost = { type = "boolean", description = "Whether to make the file the active editor tab. If false, the file will be opened in the background without changing focus.", default = true, }, }, required = { "filePath" }, additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Finds a suitable main editor window to open files in. ---Excludes terminals, sidebars, and floating windows. ---@return integer? win_id Window ID of the main editor window, or nil if not found local function find_main_editor_window() local windows = vim.api.nvim_list_wins() for _, win in ipairs(windows) do local buf = vim.api.nvim_win_get_buf(win) local buftype = vim.api.nvim_buf_get_option(buf, "buftype") local filetype = vim.api.nvim_buf_get_option(buf, "filetype") local win_config = vim.api.nvim_win_get_config(win) -- Check if this is a suitable window local is_suitable = true -- Skip floating windows if win_config.relative and win_config.relative ~= "" then is_suitable = false end -- Skip special buffer types if is_suitable and (buftype == "terminal" or buftype == "nofile" or buftype == "prompt") then is_suitable = false end -- Skip known sidebar filetypes if is_suitable and ( filetype == "neo-tree" or filetype == "neo-tree-popup" or filetype == "NvimTree" or filetype == "oil" or filetype == "minifiles" or filetype == "aerial" or filetype == "tagbar" ) then is_suitable = false end -- This looks like a main editor window if is_suitable then return win end end return nil end --- Handles the openFile tool invocation. --- Opens a file in the editor with optional selection. ---@param params table The input parameters for the tool ---@return table MCP-compliant response with content array local function handler(params) if not params.filePath then error({ code = -32602, message = "Invalid params", data = "Missing filePath parameter" }) end local file_path = vim.fn.expand(params.filePath) if vim.fn.filereadable(file_path) == 0 then -- Using a generic error code for tool-specific operational errors error({ code = -32000, message = "File operation error", data = "File not found: " .. file_path }) end -- Set default values for optional parameters local preview = params.preview or false local make_frontmost = params.makeFrontmost ~= false -- default true local select_to_end_of_line = params.selectToEndOfLine or false local message = "Opened file: " .. file_path -- Find the main editor window local target_win = find_main_editor_window() if target_win then -- Open file in the target window vim.api.nvim_win_call(target_win, function() if preview then vim.cmd("pedit " .. vim.fn.fnameescape(file_path)) else vim.cmd("edit " .. vim.fn.fnameescape(file_path)) end end) -- Focus the window after opening if makeFrontmost is true if make_frontmost then vim.api.nvim_set_current_win(target_win) end else -- Fallback: Create a new window if no suitable window found -- Try to move to a better position vim.cmd("wincmd t") -- Go to top-left vim.cmd("wincmd l") -- Move right (to middle if layout is left|middle|right) -- If we're still in a special window, create a new split local buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) local buftype = vim.api.nvim_buf_get_option(buf, "buftype") if buftype == "terminal" or buftype == "nofile" then vim.cmd("vsplit") end if preview then vim.cmd("pedit " .. vim.fn.fnameescape(file_path)) else vim.cmd("edit " .. vim.fn.fnameescape(file_path)) end end -- Handle text selection by line numbers if params.startLine or params.endLine then local start_line = params.startLine or 1 local end_line = params.endLine or start_line -- Convert to 0-based indexing for vim API local start_pos = { start_line - 1, 0 } local end_pos = { end_line - 1, -1 } -- -1 means end of line vim.api.nvim_buf_set_mark(0, "<", start_pos[1], start_pos[2], {}) vim.api.nvim_buf_set_mark(0, ">", end_pos[1], end_pos[2], {}) vim.cmd("normal! gv") message = "Opened file and selected lines " .. start_line .. " to " .. end_line end -- Handle text pattern selection if params.startText then local buf = vim.api.nvim_get_current_buf() local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local start_line_idx, start_col_idx local end_line_idx, end_col_idx -- Find start text for line_idx, line in ipairs(lines) do local col_idx = string.find(line, params.startText, 1, true) -- plain text search if col_idx then start_line_idx = line_idx - 1 -- Convert to 0-based start_col_idx = col_idx - 1 -- Convert to 0-based break end end if start_line_idx then -- Find end text if provided if params.endText then for line_idx = start_line_idx + 1, #lines do local line = lines[line_idx] -- Access current line directly if line then local col_idx = string.find(line, params.endText, 1, true) if col_idx then end_line_idx = line_idx end_col_idx = col_idx + string.len(params.endText) - 1 if select_to_end_of_line then end_col_idx = string.len(line) end break end end end if end_line_idx then message = 'Opened file and selected text from "' .. params.startText .. '" to "' .. params.endText .. '"' else -- End text not found, select only start text end_line_idx = start_line_idx end_col_idx = start_col_idx + string.len(params.startText) - 1 message = 'Opened file and positioned at "' .. params.startText .. '" (end text "' .. params.endText .. '" not found)' end else -- Only start text provided end_line_idx = start_line_idx end_col_idx = start_col_idx + string.len(params.startText) - 1 message = 'Opened file and selected text "' .. params.startText .. '"' end -- Apply the selection vim.api.nvim_win_set_cursor(0, { start_line_idx + 1, start_col_idx }) vim.api.nvim_buf_set_mark(0, "<", start_line_idx, start_col_idx, {}) vim.api.nvim_buf_set_mark(0, ">", end_line_idx, end_col_idx, {}) vim.cmd("normal! gv") vim.cmd("normal! zz") -- Center the selection in the window else message = 'Opened file, but text "' .. params.startText .. '" not found' end end -- Return format based on makeFrontmost parameter if make_frontmost then -- Simple message format when makeFrontmost=true return { content = { { type = "text", text = message, }, }, } else -- Detailed JSON format when makeFrontmost=false local buf = vim.api.nvim_get_current_buf() local detailed_info = { success = true, filePath = file_path, languageId = vim.api.nvim_buf_get_option(buf, "filetype"), lineCount = vim.api.nvim_buf_line_count(buf), } return { content = { { type = "text", text = vim.json.encode(detailed_info, { indent = 2 }), }, }, } end end return { name = "openFile", schema = schema, handler = handler, } ================================================ FILE: lua/claudecode/tools/save_document.lua ================================================ --- Tool implementation for saving a document. local schema = { description = "Save a document with unsaved changes", inputSchema = { type = "object", properties = { filePath = { type = "string", description = "Path to the file to save", }, }, required = { "filePath" }, additionalProperties = false, ["$schema"] = "http://json-schema.org/draft-07/schema#", }, } ---Handles the saveDocument tool invocation. ---Saves the specified file (buffer). ---@param params table The input parameters for the tool ---@return table MCP-compliant response with save status local function handler(params) if not params.filePath then error({ code = -32602, message = "Invalid params", data = "Missing filePath parameter", }) end local bufnr = vim.fn.bufnr(params.filePath) if bufnr == -1 then -- Return failure when document not open, matching VS Code behavior return { content = { { type = "text", text = vim.json.encode({ success = false, message = "Document not open: " .. params.filePath, }, { indent = 2 }), }, }, } end local success, err = pcall(vim.api.nvim_buf_call, bufnr, function() vim.cmd("write") end) if not success then return { content = { { type = "text", text = vim.json.encode({ success = false, message = "Failed to save file: " .. tostring(err), filePath = params.filePath, }, { indent = 2 }), }, }, } end -- Return MCP-compliant format with JSON-stringified success result return { content = { { type = "text", text = vim.json.encode({ success = true, filePath = params.filePath, saved = true, message = "Document saved successfully", }, { indent = 2 }), }, }, } end return { name = "saveDocument", schema = schema, handler = handler, } ================================================ FILE: plugin/claudecode.lua ================================================ if vim.fn.has("nvim-0.8.0") ~= 1 then vim.api.nvim_err_writeln("Claude Code requires Neovim >= 0.8.0") return end if vim.g.loaded_claudecode then return end vim.g.loaded_claudecode = 1 --- Example: In your `init.lua`, you can set `vim.g.claudecode_auto_setup = { auto_start = true }` --- to automatically start ClaudeCode when Neovim loads. if vim.g.claudecode_auto_setup then vim.defer_fn(function() require("claudecode").setup(vim.g.claudecode_auto_setup) end, 0) end -- Commands are now registered in lua/claudecode/init.lua's _create_commands function -- when require("claudecode").setup() is called. -- This file (plugin/claudecode.lua) is primarily for the load guard -- and the optional auto-setup mechanism. local main_module_ok, _ = pcall(require, "claudecode") if not main_module_ok then vim.notify("ClaudeCode: Failed to load main module. Plugin may not function correctly.", vim.log.levels.ERROR) end ================================================ FILE: scripts/claude_interactive.sh ================================================ #!/usr/bin/env bash # claude_interactive.sh - Interactive script for working with Claude Code WebSocket API # This script provides a menu-driven interface for common operations # Source the libraries SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./lib_claude.sh source "$SCRIPT_DIR/lib_claude.sh" # shellcheck source=./lib_ws_persistent.sh source "$SCRIPT_DIR/lib_ws_persistent.sh" # Configuration export CLAUDE_LOG_DIR="mcp_interactive_logs" mkdir -p "$CLAUDE_LOG_DIR" CONN_ID="claude_interactive" # Terminal colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Check if Claude Code is running if ! claude_is_running; then echo -e "${RED}Claude Code doesn't appear to be running.${NC}" echo "Please start Claude Code and try again." exit 1 fi # Get WebSocket URL and authentication info WS_URL=$(get_claude_ws_url) PORT=$(find_claude_lockfile) AUTH_TOKEN=$(get_claude_auth_token "$PORT") # Initialize WebSocket connection echo -e "${BLUE}Initializing WebSocket connection to ${WS_URL}...${NC}" if ! ws_connect "$WS_URL" "$CONN_ID" "$AUTH_TOKEN"; then echo -e "${RED}Failed to establish connection.${NC}" exit 1 fi # Send initial connection handshake echo -e "${BLUE}Sending initial handshake...${NC}" # Format JSON to a single line for proper WebSocket transmission HANDSHAKE_PARAMS=$(ws_format_json '{ "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "clientInfo": { "name": "claude-nvim-client", "version": "0.2.0" } }') ws_notify "mcp.connect" "$HANDSHAKE_PARAMS" "$CONN_ID" # Display header clear echo -e "${BLUE}========================================${NC}" echo -e "${BLUE} Claude Code Interactive CLI ${NC}" echo -e "${BLUE}========================================${NC}" echo -e "${GREEN}Connected to WebSocket:${NC} $WS_URL" echo -e "${GREEN}Using connection:${NC} $CONN_ID" echo # Function to display menu show_menu() { echo -e "${YELLOW}Available Commands:${NC}" echo " 1) Get current selection" echo " 2) List available tools" echo " 3) Open a file" echo " 4) Send custom JSON-RPC message" echo " 5) Initialize session" echo " 6) Show connection info" echo " 7) Listen for messages" echo " 8) Reconnect WebSocket" echo " 9) Exit" echo echo -n "Enter your choice [1-9]: " } # Function to handle getting current selection handle_get_selection() { echo -e "${BLUE}Getting current selection...${NC}" response=$(ws_rpc_request "tools/call" '{"name":"getCurrentSelection","arguments":{}}' "selection-$(date +%s)" "$CONN_ID") if echo "$response" | grep -q '"error"'; then echo -e "${RED}Error:${NC}" echo "$response" | jq .error else echo -e "${GREEN}Selection information:${NC}" echo "$response" | jq . # Extract selection text if available - handle both direct response and nested content selection_text=$(echo "$response" | jq -r '.result.text // .result.content[0].text // "No text selected"') if [ "$selection_text" != "No text selected" ] && [ "$selection_text" != "null" ]; then echo -e "${YELLOW}Selected text:${NC}" echo "$selection_text" fi fi } # Function to handle listing tools handle_list_tools() { echo -e "${BLUE}Listing available tools...${NC}" response=$(ws_rpc_request "tools/list" "{}" "tools-$(date +%s)" "$CONN_ID") if echo "$response" | grep -q '"error"'; then echo -e "${RED}Error:${NC}" echo "$response" | jq .error else echo -e "${GREEN}Available tools:${NC}" echo "$response" | jq -r '.result.tools[] | .name' 2>/dev/null | sort | while read -r tool; do echo " - $tool" done echo echo -e "${YELLOW}Total tools:${NC} $(echo "$response" | jq '.result.tools | length' 2>/dev/null || echo "unknown")" fi } # Function to handle opening a file handle_open_file() { echo -n "Enter file path (absolute or relative): " read -r file_path if [ -z "$file_path" ]; then echo -e "${RED}File path cannot be empty.${NC}" return fi # Convert to absolute path if relative if [[ $file_path != /* ]]; then file_path="$(realpath "$file_path" 2>/dev/null)" if ! realpath "$file_path" &>/dev/null; then echo -e "${RED}Invalid file path: $file_path${NC}" return fi fi if [ ! -f "$file_path" ]; then echo -e "${RED}File does not exist: $file_path${NC}" return fi echo -e "${BLUE}Opening file:${NC} $file_path" # Format the JSON parameters to a single line PARAMS=$(ws_format_json "{\"name\":\"openFile\",\"arguments\":{\"filePath\":\"$file_path\",\"startText\":\"\",\"endText\":\"\"}}") response=$(ws_rpc_request "tools/call" "$PARAMS" "open-file-$(date +%s)" "$CONN_ID") if echo "$response" | grep -q '"error"'; then echo -e "${RED}Error:${NC}" echo "$response" | jq .error else echo -e "${GREEN}File opened successfully.${NC}" fi } # Function to handle sending a custom message handle_custom_message() { echo "Enter method name (e.g., tools/list, getCurrentSelection):" read -r method if [ -z "$method" ]; then echo -e "${RED}Method name cannot be empty.${NC}" return fi echo "Enter parameters as JSON (default: {}):" read -r params # Use empty object if no params provided if [ -z "$params" ]; then params="{}" fi # Validate JSON if ! echo "$params" | jq . >/dev/null 2>&1; then echo -e "${RED}Invalid JSON parameters.${NC}" return fi # Format params to single line JSON params=$(ws_format_json "$params") echo "Enter request ID (default: custom-$(date +%s)):" read -r id # Use default ID if none provided if [ -z "$id" ]; then id="custom-$(date +%s)" fi echo -e "${BLUE}Sending custom message:${NC}" # Create message in proper single-line format request=$(ws_create_message "$method" "$params" "$id") echo "$request" | jq . echo -e "${BLUE}Response:${NC}" response=$(ws_request "$request" "$CONN_ID" 5) echo "$response" | jq . } # Function to handle initializing a session handle_initialize() { echo -e "${BLUE}Initializing Claude session...${NC}" # Format init params to single line INIT_PARAMS=$(ws_format_json '{ "protocolVersion": "2025-03-26", "capabilities": { "roots": { "listChanged": true }, "sampling": {} }, "clientInfo": { "name": "ClaudeCodeNvim", "version": "0.2.0" } }') response=$(ws_rpc_request "initialize" "$INIT_PARAMS" "init-$(date +%s)" "$CONN_ID") if echo "$response" | grep -q '"error"'; then echo -e "${RED}Initialization error:${NC}" echo "$response" | jq .error else echo -e "${GREEN}Session initialized successfully:${NC}" echo "$response" | jq . # Send initialized notification ws_notify "initialized" "{}" "$CONN_ID" echo -e "${GREEN}Sent initialized notification${NC}" # Extract protocol version protocol=$(echo "$response" | jq -r '.result.protocolVersion // "unknown"') echo -e "${YELLOW}Protocol version:${NC} $protocol" fi } # Function to display connection info handle_connection_info() { echo -e "${BLUE}Connection Information:${NC}" echo -e "${YELLOW}WebSocket URL:${NC} $WS_URL" echo -e "${YELLOW}Port:${NC} $PORT" echo -e "${YELLOW}Lock file:${NC} $CLAUDE_LOCKFILE_DIR/$PORT.lock" if [ -f "$CLAUDE_LOCKFILE_DIR/$PORT.lock" ]; then echo -e "${YELLOW}Lock file contents:${NC}" cat "$CLAUDE_LOCKFILE_DIR/$PORT.lock" fi # Display WebSocket connection info if ws_is_connected "$CONN_ID"; then echo -e "${YELLOW}WebSocket connection status:${NC} ${GREEN}Active${NC}" else echo -e "${YELLOW}WebSocket connection status:${NC} ${RED}Inactive${NC}" fi } # Callback function for message listener process_interactive_message() { local message="$1" local count="$2" echo -e "${GREEN}Message #$count received:${NC}" echo "$message" | jq . echo } # Function to listen for messages handle_listen() { echo -e "${BLUE}Listening for WebSocket messages...${NC}" echo "Press Ctrl+C to stop listening." # Start the listener with our callback function ws_start_listener "$CONN_ID" process_interactive_message # We'll use a simple loop to keep this function running until Ctrl+C while true; do sleep 1 done # The ws_stop_listener will be called by the trap in the main script } # Function to reconnect WebSocket handle_reconnect() { echo -e "${BLUE}Reconnecting WebSocket...${NC}" # Disconnect and reconnect ws_disconnect "$CONN_ID" if ws_connect "$WS_URL" "$CONN_ID"; then echo -e "${GREEN}Reconnection successful.${NC}" # Send handshake again HANDSHAKE_PARAMS=$(ws_format_json '{ "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "clientInfo": { "name": "claude-nvim-client", "version": "0.2.0" } }') ws_notify "mcp.connect" "$HANDSHAKE_PARAMS" "$CONN_ID" else echo -e "${RED}Reconnection failed.${NC}" fi } # Main loop while true; do show_menu read -r choice echo case $choice in 1) handle_get_selection ;; 2) handle_list_tools ;; 3) handle_open_file ;; 4) handle_custom_message ;; 5) handle_initialize ;; 6) handle_connection_info ;; 7) # Handle Ctrl+C gracefully for the listen function trap 'echo -e "\n${YELLOW}Stopped listening.${NC}"; ws_stop_listener "$CONN_ID"; trap - INT; break' INT handle_listen trap - INT ;; 8) handle_reconnect ;; 9) echo "Cleaning up connections and exiting..." ws_stop_listener "$CONN_ID" 2>/dev/null # Stop any listener if active ws_disconnect "$CONN_ID" exit 0 ;; *) echo -e "${RED}Invalid choice. Please try again.${NC}" ;; esac echo echo -n "Press Enter to continue..." read -r clear echo -e "${BLUE}========================================${NC}" echo -e "${BLUE} Claude Code Interactive CLI ${NC}" echo -e "${BLUE}========================================${NC}" echo -e "${GREEN}Connected to WebSocket:${NC} $WS_URL" echo -e "${GREEN}Using connection:${NC} $CONN_ID" echo done ================================================ FILE: scripts/claude_shell_helpers.sh ================================================ #!/usr/bin/env bash # Source this file in your .zshrc or .bashrc to add Claude Code helper functions # to your interactive shell # # Example usage: # echo 'source /path/to/claude_shell_helpers.sh' >> ~/.zshrc # # Then in your shell: # $ claude_port # $ claude_get_selection # $ claude_open_file /path/to/file.txt # Get the script's directory, handling sourced scripts if [[ -n $0 && $0 != "-bash" && $0 != "-zsh" ]]; then CLAUDE_LIB_DIR="$(dirname "$(realpath "$0")")" else # Fallback when being sourced in a shell CLAUDE_LIB_DIR="${HOME}/.claude/bin" fi # Source the main library # shellcheck source=./lib_claude.sh source "$CLAUDE_LIB_DIR/lib_claude.sh" # Set default log directory relative to home export CLAUDE_LOG_DIR="$HOME/.claude/logs" mkdir -p "$CLAUDE_LOG_DIR" 2>/dev/null # Function to get and print Claude Code port claude_port() { find_claude_lockfile } # Function to get websocket URL claude_ws_url() { get_claude_ws_url } # Function to check if Claude Code is running claude_running() { if claude_is_running; then local port port=$(find_claude_lockfile 2>/dev/null) echo "Claude Code is running (port: $port)" return 0 else echo "Claude Code is not running" return 1 fi } # Function to get current selection claude_get_selection() { if claude_is_running; then local response response=$(get_current_selection) # Pretty print the whole response if -v/--verbose flag is provided if [[ $1 == "-v" || $1 == "--verbose" ]]; then echo "$response" | jq . return fi # Otherwise just output the selection text local selection selection=$(echo "$response" | jq -r '.result.text // "No text selected"') if [[ $selection != "No text selected" && $selection != "null" ]]; then echo "$selection" else echo "No text currently selected" fi else echo "Error: Claude Code is not running" >&2 return 1 fi } # Function to open a file in Claude Code claude_open_file() { if [ -z "$1" ]; then echo "Usage: claude_open_file " >&2 return 1 fi local file_path="$1" # Convert to absolute path if relative if [[ $file_path != /* ]]; then file_path="$(realpath "$file_path" 2>/dev/null)" if ! realpath "$file_path" &>/dev/null; then echo "Error: Invalid file path" >&2 return 1 fi fi if [ ! -f "$file_path" ]; then echo "Error: File does not exist: $file_path" >&2 return 1 fi if claude_is_running; then open_file "$file_path" >/dev/null echo "Opened: $file_path" else echo "Error: Claude Code is not running" >&2 return 1 fi } # Function to list available tools claude_list_tools() { if claude_is_running; then local response response=$(list_claude_tools) # Pretty print the whole response if -v/--verbose flag is provided if [[ $1 == "-v" || $1 == "--verbose" ]]; then echo "$response" | jq . return fi # Otherwise just list tool names echo "$response" | jq -r '.result.tools[].name' 2>/dev/null | sort else echo "Error: Claude Code is not running" >&2 return 1 fi } # Function to send a custom message claude_send() { if [ $# -lt 2 ]; then echo "Usage: claude_send [request_id]" >&2 echo "Example: claude_send 'getCurrentSelection' '{}' 'my-id'" >&2 return 1 fi local method="$1" local params="$2" local id="${3:-$(uuidgen)}" if claude_is_running; then local message message=$(create_message "$method" "$params" "$id") local response response=$(send_claude_message "$message") echo "$response" | jq . else echo "Error: Claude Code is not running" >&2 return 1 fi } # Launch the interactive tool claude_interactive() { "$CLAUDE_LIB_DIR/claude_interactive.sh" } # Print help for shell functions claude_help() { cat < - Open a file in Claude Code claude_list_tools - List available tools claude_list_tools -v - List tools with details (verbose) claude_send [id] - Send a custom JSON-RPC message claude_interactive - Launch the interactive CLI claude_help - Show this help message Examples: $ claude_port $ claude_get_selection $ claude_open_file ~/project/src/main.js $ claude_send 'getCurrentSelection' '{}' EOL } # Check if sourced in an interactive shell if [[ $- == *i* ]]; then echo "Claude Code shell helpers loaded. Type 'claude_help' for available commands." fi ================================================ FILE: scripts/lib_claude.sh ================================================ #!/usr/bin/env bash # lib_claude.sh - Common functions for Claude Code MCP testing and interaction # This library provides reusable functions for interacting with Claude Code's WebSocket API # Configuration if [ -n "$CLAUDE_CONFIG_DIR" ]; then export CLAUDE_LOCKFILE_DIR="$CLAUDE_CONFIG_DIR/ide" else export CLAUDE_LOCKFILE_DIR="$HOME/.claude/ide" fi export CLAUDE_LOG_DIR="mcp_test_logs" # Default log directory export CLAUDE_WS_TIMEOUT=10 # Default timeout in seconds # Find the Claude Code lock file and extract the port # Returns the port number on success, or empty string on failure # Usage: PORT=$(find_claude_lockfile) # Find the Claude lockfile and extract the port find_claude_lockfile() { # Get all .lock files lock_files=$(find "$CLAUDE_LOCKFILE_DIR" -name "*.lock" 2>/dev/null || echo "") if [ -z "$lock_files" ]; then echo "No Claude lockfiles found. Is the VSCode extension running?" >&2 return 1 fi # Process each lock file newest_file="" newest_time=0 for file in $lock_files; do # Get file modification time using stat file_time=$(stat -c "%Y" "$file" 2>/dev/null) # Update if this is newer if [[ -n $file_time ]] && [[ $file_time -gt $newest_time ]]; then newest_time=$file_time newest_file=$file fi done if [ -n "$newest_file" ]; then # Extract port from filename port=$(basename "$newest_file" .lock) echo "$port" return 0 else echo "No valid lock files found" >&2 return 1 fi } # Get the WebSocket URL for Claude Code # Usage: WS_URL=$(get_claude_ws_url) get_claude_ws_url() { local port port=$(find_claude_lockfile) if [[ ! $port =~ ^[0-9]+$ ]]; then echo >&2 "Error: Invalid port number: '$port'" echo >&2 "Is Claude Code running?" return 1 fi echo "ws://localhost:$port" } # Get the authentication token from a Claude Code lock file # Usage: AUTH_TOKEN=$(get_claude_auth_token "$PORT") get_claude_auth_token() { local port="$1" if [[ -z $port ]]; then echo >&2 "Error: Port number required" return 1 fi local lock_file="$CLAUDE_LOCKFILE_DIR/$port.lock" if [[ ! -f $lock_file ]]; then echo >&2 "Error: Lock file not found: $lock_file" return 1 fi # Extract authToken from JSON using jq if available, otherwise basic parsing if command -v jq >/dev/null 2>&1; then local auth_token auth_token=$(jq -r '.authToken // empty' "$lock_file" 2>/dev/null) if [[ -z $auth_token ]]; then echo >&2 "Error: No authToken found in lock file" return 1 fi echo "$auth_token" else # Fallback parsing without jq local auth_token auth_token=$(grep -o '"authToken"[[:space:]]*:[[:space:]]*"[^"]*"' "$lock_file" | sed 's/.*"authToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') if [[ -z $auth_token ]]; then echo >&2 "Error: No authToken found in lock file (install jq for better JSON parsing)" return 1 fi echo "$auth_token" fi } # Create a JSON-RPC request message (with ID) # Usage: MSG=$(create_message "method_name" '{"param":"value"}' "request-id") create_message() { local method="$1" local params="$2" local id="$3" cat </dev/null || echo '{"error":{"code":-32000,"message":"Timeout waiting for response"}}' } # Initialize a log directory for test output # Usage: init_log_dir "test_name" init_log_dir() { local test_name="${1:-test}" local log_dir="${CLAUDE_LOG_DIR}" mkdir -p "$log_dir" local log_file="$log_dir/${test_name}.jsonl" local pretty_log="$log_dir/${test_name}_pretty.txt" # Clear previous log files : >"$log_file" : >"$pretty_log" echo "$log_file:$pretty_log" } # Log a message and response to log files # Usage: log_message_and_response "$MSG" "$RESPONSE" "$LOG_FILE" "$PRETTY_LOG" "Request description" log_message_and_response() { local message="$1" local response="$2" local log_file="$3" local pretty_log="$4" local description="${5:-Message}" # Log the raw request and response echo "$message" >>"$log_file" echo "$response" >>"$log_file" # Log pretty-formatted request and response echo -e "\n--- $description Request ---" >>"$pretty_log" echo "$message" | jq '.' >>"$pretty_log" 2>/dev/null || echo "$message" >>"$pretty_log" echo -e "\n--- $description Response ---" >>"$pretty_log" echo "$response" | jq '.' >>"$pretty_log" 2>/dev/null || echo "Invalid JSON: $response" >>"$pretty_log" } # Test if Claude Code is running by checking for a valid WebSocket port # Usage: if claude_is_running; then echo "Claude is running!"; fi claude_is_running() { local port port=$(find_claude_lockfile 2>/dev/null) [[ $port =~ ^[0-9]+$ ]] } # Simple tools/list call to check if connection is working # Usage: TOOLS=$(list_claude_tools) list_claude_tools() { local ws_url ws_url=$(get_claude_ws_url) local msg msg=$(create_message "tools/list" "{}" "tools-list") local response response=$(send_claude_message "$msg" "$ws_url") echo "$response" } # Get the current selection in the editor # Usage: SELECTION=$(get_current_selection) get_current_selection() { local ws_url ws_url=$(get_claude_ws_url) local msg msg=$(create_message "tools/call" '{"name":"getCurrentSelection","arguments":{}}' "get-selection") local response response=$(send_claude_message "$msg" "$ws_url") echo "$response" } # Open a file in the editor # Usage: open_file "/path/to/file.txt" open_file() { local file_path="$1" local ws_url ws_url=$(get_claude_ws_url) # Ensure absolute path if [[ $file_path != /* ]]; then file_path="$(realpath "$file_path")" fi local msg msg=$(create_message "tools/call" "{\"name\":\"openFile\",\"arguments\":{\"filePath\":\"$file_path\",\"startText\":\"\",\"endText\":\"\"}}" "open-file") send_claude_message "$msg" "$ws_url" >/dev/null return $? } # Check if a command exists # Usage: if command_exists "websocat"; then echo "websocat is installed"; fi command_exists() { command -v "$1" >/dev/null 2>&1 } # Check if required tools are installed # Usage: check_required_tools check_required_tools() { local missing=0 if ! command_exists "websocat"; then echo >&2 "Error: websocat is not installed. Please install it to use this library." echo >&2 " - On macOS: brew install websocat" echo >&2 " - On Linux: cargo install websocat" missing=1 fi if ! command_exists "jq"; then echo >&2 "Error: jq is not installed. Please install it to use this library." echo >&2 " - On macOS: brew install jq" echo >&2 " - On Linux: apt-get install jq or yum install jq" missing=1 fi return $missing } # Perform a complete initialization sequence with Claude Code # Usage: RESULT=$(initialize_claude_session) initialize_claude_session() { local ws_url ws_url=$(get_claude_ws_url) # Send initialize request local init_msg init_msg=$(create_init_message "init-1") local init_response init_response=$(send_claude_message "$init_msg" "$ws_url") # Send initialized notification local init_notification init_notification=$(create_notification "initialized" "") send_claude_message "$init_notification" "$ws_url" >/dev/null # Return the initialization response echo "$init_response" } # Check environment and required tools when the library is sourced if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then # Running as a script - show usage info echo "Claude Code Library - Common functions for interacting with Claude Code" echo echo "This script is meant to be sourced in other scripts:" echo " source ${BASH_SOURCE[0]}" echo echo "Example usage in interactive shell:" echo ' PORT=$(find_claude_lockfile)' echo ' WS_URL=$(get_claude_ws_url)' echo ' TOOLS=$(list_claude_tools)' echo else # Running as a sourced library - check required tools check_required_tools fi ================================================ FILE: scripts/lib_ws_persistent.sh ================================================ #!/usr/bin/env bash # lib_ws_persistent.sh - Library for persistent WebSocket connections # A simpler, more reliable implementation # Configuration WS_LOG_DIR="${TMPDIR:-/tmp}/ws_logs" mkdir -p "$WS_LOG_DIR" # Store active connections declare -A WS_CONNECTIONS declare -A WS_REQUEST_FILES # Start a persistent WebSocket connection # ws_connect URL [CONN_ID] [AUTH_TOKEN] ws_connect() { local url="$1" local conn_id="${2:-default}" local auth_token="${3:-}" # Cleanup any existing connection with this ID ws_disconnect "$conn_id" # Create connection log directory local log_dir="$WS_LOG_DIR/$conn_id" mkdir -p "$log_dir" # Files for this connection local pid_file="$log_dir/pid" local log_file="$log_dir/log.txt" local request_file="$log_dir/request.json" local response_file="$log_dir/response.json" # Store request file path for later use WS_REQUEST_FILES[$conn_id]="$request_file" # Create empty files : >"$request_file" : >"$response_file" # This uses a simpler approach - websocat runs in the background and: # 1. Reads JSON requests from request_file # 2. Writes all server responses to response_file ( # Note: The -E flag makes websocat exit when the file is closed if [ -n "$auth_token" ]; then # Use websocat with auth header - avoid eval by constructing command safely tail -f "$request_file" | websocat -t --header "x-claude-code-ide-authorization: $auth_token" "$url" | tee -a "$response_file" >"$log_file" & else # Use websocat without auth header tail -f "$request_file" | websocat -t "$url" | tee -a "$response_file" >"$log_file" & fi # Save PID echo $! >"$pid_file" # Wait for process to finish wait ) & # Save the background process group ID local pgid=$! WS_CONNECTIONS[$conn_id]="$pgid|$log_dir" # Wait briefly for connection to establish sleep 0.5 # Check if process is still running if ws_is_connected "$conn_id"; then return 0 else return 1 fi } # Check if a connection is active # ws_is_connected [CONN_ID] ws_is_connected() { local conn_id="${1:-default}" # Check if we have this connection if [[ -z ${WS_CONNECTIONS[$conn_id]} ]]; then return 1 fi # Get connection info local info="${WS_CONNECTIONS[$conn_id]}" local pgid pgid=$(echo "$info" | cut -d'|' -f1) local log_dir log_dir=$(echo "$info" | cut -d'|' -f2) local pid_file="$log_dir/pid" # Check if process is running if [[ -f $pid_file ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then return 0 else return 1 fi } # Disconnect and clean up a connection # ws_disconnect [CONN_ID] ws_disconnect() { local conn_id="${1:-default}" # Check if we have this connection if [[ -z ${WS_CONNECTIONS[$conn_id]} ]]; then return 0 fi # Get connection info local info="${WS_CONNECTIONS[$conn_id]}" local pgid pgid=$(echo "$info" | cut -d'|' -f1) local log_dir log_dir=$(echo "$info" | cut -d'|' -f2) local pid_file="$log_dir/pid" # Kill the process group if [[ -f $pid_file ]]; then local pid pid=$(cat "$pid_file") kill "$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null fi # Remove from tracking unset "WS_CONNECTIONS[$conn_id]" unset "WS_REQUEST_FILES[$conn_id]" return 0 } # Send a message and get the response # ws_request JSON_MESSAGE [CONN_ID] [TIMEOUT] ws_request() { local message="$1" local conn_id="${2:-default}" local timeout="${3:-10}" # Make sure we're connected if ! ws_is_connected "$conn_id"; then echo >&2 "Error: Not connected with ID $conn_id" return 1 fi # Get the request file local request_file="${WS_REQUEST_FILES[$conn_id]}" # Get connection info local info="${WS_CONNECTIONS[$conn_id]}" local log_dir log_dir=$(echo "$info" | cut -d'|' -f2) local response_file="$log_dir/response.json" local temp_response_file="${log_dir}/temp_response.$$" # Extract message ID for matching response local id id=$(echo "$message" | jq -r '.id // empty' 2>/dev/null) if [[ -z $id ]]; then echo >&2 "Error: Message has no ID field" return 1 fi # Save current position in response file local start_pos start_pos=$(wc -c <"$response_file") # Send the message echo "$message" >>"$request_file" # Create empty temp file true >"$temp_response_file" # Wait for response with matching ID local end_time=$(($(date +%s) + timeout)) local response_found=false # Log request for debugging echo "Request ID: $id - $(date +%H:%M:%S)" >>"$log_dir/debug.log" echo "$message" >>"$log_dir/debug.log" while [[ $(date +%s) -lt $end_time ]] && [[ $response_found == "false" ]]; do # Check for new data in the response file if [[ -s $response_file && $(wc -c <"$response_file") -gt $start_pos ]]; then # Extract new responses local new_data new_data=$(tail -c +$((start_pos + 1)) "$response_file") # Write to temp file first to allow process substitution to work correctly echo "$new_data" >"$temp_response_file" # Process each line and check for matching ID while IFS= read -r line; do if [[ -z $line ]]; then continue fi # Log response for debugging echo "Checking response: $(echo "$line" | jq -r '.id // "no-id"')" >>"$log_dir/debug.log" # Parse response ID local response_id response_id=$(echo "$line" | jq -r '.id // empty' 2>/dev/null) if [[ $response_id == "$id" ]]; then # Found matching response - need to echo outside the loop echo "$line" >"$temp_response_file.found" response_found=true break fi done <"$temp_response_file" # Update position for next check start_pos=$(wc -c <"$response_file") fi # Short sleep to avoid CPU spinning sleep 0.1 done # Check if we found a response if [[ -f "$temp_response_file.found" ]]; then cat "$temp_response_file.found" rm -f "$temp_response_file" "$temp_response_file.found" return 0 else echo >&2 "Error: Timeout waiting for response to message with ID $id" # Return the most recent message as a fallback - it might be what we're looking for if [[ -s $temp_response_file ]]; then tail -1 "$temp_response_file" else echo "{\"jsonrpc\":\"2.0\",\"id\":\"$id\",\"error\":{\"code\":-32000,\"message\":\"Timeout waiting for response\"}}" fi rm -f "$temp_response_file" "$temp_response_file.found" return 0 # Still return success to allow processing the fallback response fi } # Send a notification (no response expected) # ws_notify METHOD PARAMS [CONN_ID] ws_notify() { local method="$1" local params="$2" local conn_id="${3:-default}" # Format JSON-RPC notification on a single line local notification="{ \"jsonrpc\": \"2.0\", \"method\": \"$method\", \"params\": $params }" # Make sure we're connected if ! ws_is_connected "$conn_id"; then echo >&2 "Error: Not connected with ID $conn_id" return 1 fi # Get the request file local request_file="${WS_REQUEST_FILES[$conn_id]}" # Send the notification echo "$notification" >>"$request_file" return 0 } # Send a JSON-RPC request and wait for response # ws_rpc_request METHOD PARAMS [ID] [CONN_ID] [TIMEOUT] ws_rpc_request() { local method="$1" local params="$2" local id="${3:-req-$(date +%s)}" local conn_id="${4:-default}" local timeout="${5:-10}" # Special handling for legacy mcp.connect and current initialize if [[ $method == "mcp.connect" ]]; then # For backward compatibility, support the old method # Send the message without waiting for response ws_notify "$method" "$params" "$conn_id" # Return fake success response echo "{\"jsonrpc\":\"2.0\",\"id\":\"$id\",\"result\":{\"message\":\"Connected\"}}" return 0 fi # No special handling needed for initialize - it's a proper JSON-RPC request that expects a response # Format JSON-RPC request on a single line local request="{ \"jsonrpc\": \"2.0\", \"id\": \"$id\", \"method\": \"$method\", \"params\": $params }" # Send request and wait for response ws_request "$request" "$conn_id" "$timeout" } # Create a JSON-RPC message (for use with ws_request) # ws_create_message METHOD PARAMS [ID] ws_create_message() { local method="$1" local params="$2" local id="${3:-msg-$(date +%s)}" # Output a single-line JSON message echo "{ \"jsonrpc\": \"2.0\", \"id\": \"$id\", \"method\": \"$method\", \"params\": $params }" } # Format JSON object to a single line (useful for preparing params) # ws_format_json "JSON_OBJECT" ws_format_json() { local json="$1" # Use jq to normalize and compact the JSON echo "$json" | jq -c '.' } # Clean up all connections ws_cleanup_all() { for id in "${!WS_CONNECTIONS[@]}"; do ws_disconnect "$id" done } # Set up trap to clean up all connections on exit ws_setup_trap() { trap ws_cleanup_all EXIT INT TERM } # Start a message listener in the background # ws_start_listener CONN_ID CALLBACK_FUNCTION [FILTER_METHOD] # Example: ws_start_listener "my_conn" process_message "selection_changed" ws_start_listener() { local conn_id="${1:-default}" local callback_function="$2" local filter_method="${3:-}" # Check if connection exists if ! ws_is_connected "$conn_id"; then echo >&2 "Error: Connection $conn_id not active" return 1 fi # Get connection info local info="${WS_CONNECTIONS[$conn_id]}" local log_dir log_dir=$(echo "$info" | cut -d'|' -f2) local response_file="$log_dir/response.json" local listener_pid_file="$log_dir/listener.pid" # Start background process ( # Start position in the response file local start_pos start_pos=$(wc -c <"$response_file") local count=0 while true; do # Check for new data in the response file if [[ -s $response_file && $(wc -c <"$response_file") -gt $start_pos ]]; then # Extract new responses local new_data new_data=$(tail -c +$((start_pos + 1)) "$response_file") # Process each line echo "$new_data" | while IFS= read -r message; do if [[ -z $message ]]; then continue fi # If filter method is specified, only process matching messages if [[ -z $filter_method ]] || echo "$message" | grep -q "\"method\":\"$filter_method\""; then count=$((count + 1)) # Call the callback function with the message and count $callback_function "$message" "$count" fi done # Update position for next check start_pos=$(wc -c <"$response_file") fi sleep 0.5 done ) & # Save PID for later cleanup echo $! >"$listener_pid_file" return 0 } # Stop a previously started listener # ws_stop_listener CONN_ID ws_stop_listener() { local conn_id="${1:-default}" # Get connection info local info="${WS_CONNECTIONS[$conn_id]}" if [[ -z $info ]]; then return 0 fi local log_dir log_dir=$(echo "$info" | cut -d'|' -f2) local listener_pid_file="$log_dir/listener.pid" if [[ -f $listener_pid_file ]]; then local pid pid=$(cat "$listener_pid_file") kill "$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null rm -f "$listener_pid_file" fi } # If script is run directly, show usage if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then echo "WebSocket Persistent Connection Library" echo "This script is meant to be sourced by other scripts:" echo " source ${BASH_SOURCE[0]}" echo echo "Available functions:" echo " ws_connect URL [CONN_ID] - Initialize connection" echo " ws_disconnect [CONN_ID] - Close connection" echo " ws_is_connected [CONN_ID] - Check if connected" echo " ws_request JSON_MESSAGE [CONN_ID] [TIMEOUT] - Send raw message and get response" echo " ws_notify METHOD PARAMS [CONN_ID] - Send notification (no response)" echo " ws_rpc_request METHOD PARAMS [ID] [CONN_ID] [TIMEOUT] - Send request and wait for response" echo " ws_create_message METHOD PARAMS [ID] - Create JSON-RPC message" echo " ws_format_json JSON_OBJECT - Format JSON to single line" echo " ws_start_listener CONN_ID CALLBACK_FUNCTION [FILTER_METHOD] - Start background listener" echo " ws_stop_listener CONN_ID - Stop a background listener" echo " ws_cleanup_all - Clean up all connections" echo " ws_setup_trap - Set up cleanup trap" echo echo "IMPORTANT NOTES:" echo " 1. All JSON must be single-line for websocat to work properly" echo " 2. Use ws_format_json to ensure your JSON is properly formatted" echo " 3. For params, use simple one-liners or compact with jq:" echo " PARAMS=\$(ws_format_json '{\"foo\": \"bar\", \"baz\": 123}')" echo echo "Example usage:" echo ' ws_connect "ws://localhost:8080" "my_conn"' echo ' ws_rpc_request "tools/list" "{}" "req-1" "my_conn"' echo ' PARAMS=$(ws_format_json "{ \"name\": \"getCurrentSelection\" }")' echo ' ws_rpc_request "tools/call" "$PARAMS" "req-2" "my_conn"' else # Set up trap if being sourced ws_setup_trap fi ================================================ FILE: scripts/manual_test_helper.lua ================================================ -- Manual test helper for openDiff -- Run this in Neovim with :luafile scripts/manual_test_helper.lua local function test_opendiff_directly() print("🧪 Testing openDiff tool directly...") -- Use the actual README.md file like the real scenario local readme_path = "/Users/thomask33/GitHub/claudecode.nvim/README.md" -- Check if README exists if vim.fn.filereadable(readme_path) == 0 then print("❌ README.md not found at", readme_path) return end -- Read the actual README content local file = io.open(readme_path, "r") if not file then print("❌ Could not read README.md") return end local original_content = file:read("*a") file:close() -- Create the same modification that Claude would make (add license section) local new_content = original_content .. "\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n" -- Load the openDiff tool local success, open_diff_tool = pcall(require, "claudecode.tools.open_diff") if not success then print("❌ Failed to load openDiff tool:", open_diff_tool) return end local params = { old_file_path = readme_path, new_file_path = readme_path, new_file_contents = new_content, tab_name = "✻ [Claude Code] README.md (test) ⧉", } print("📤 Calling openDiff handler...") print(" Old file:", params.old_file_path) print(" Tab name:", params.tab_name) print(" Original content length:", #original_content) print(" New content length:", #params.new_file_contents) -- Call in coroutine context local co = coroutine.create(function() local result = open_diff_tool.handler(params) print("📥 openDiff completed with result:", vim.inspect(result)) return result end) local start_time = vim.fn.localtime() local co_success, co_result = coroutine.resume(co) if not co_success then print("❌ openDiff failed:", co_result) return end local status = coroutine.status(co) print("🔍 Coroutine status:", status) if status == "suspended" then print("✅ openDiff is properly blocking!") print("👉 You should see a diff view now") print("👉 Save or close the diff to continue") -- Set up a timer to check when it completes local timer = vim.loop.new_timer() if not timer then print("❌ Failed to create timer") return end timer:start( 1000, 1000, vim.schedule_wrap(function() local current_status = coroutine.status(co) if current_status == "dead" then timer:stop() timer:close() local elapsed = vim.fn.localtime() - start_time print("✅ openDiff completed after " .. elapsed .. " seconds") elseif current_status ~= "suspended" then timer:stop() timer:close() print("⚠️ Unexpected coroutine status:", current_status) end end) ) else print("❌ openDiff did not block (status: " .. status .. ")") if co_result then print(" Result:", vim.inspect(co_result)) end end -- No cleanup needed since we're using the actual README file end -- Run the test test_opendiff_directly() ================================================ FILE: scripts/mcp_test.sh ================================================ #!/usr/bin/env bash # mcp_test.sh - Consolidated test script for Claude Code WebSocket MCP protocol # This script tests various aspects of the MCP protocol implementation. # # IMPORTANT: All JSON must be single-line for WebSocket JSON-RPC to work properly. # When adding or modifying JSON parameters, always use ws_format_json to ensure proper formatting. set -e CLAUDE_LIB_DIR="$(dirname "$(realpath "$0")")" source "$CLAUDE_LIB_DIR/lib_claude.sh" source "$CLAUDE_LIB_DIR/lib_ws_persistent.sh" # Configuration TIMEOUT=5 # Seconds to wait for each response LOG_DIR="mcp_test_logs" # Directory for log files # No default log file needed - each test creates its own log files WEBSOCKET_PORT="" # Will be detected automatically WAIT_BETWEEN=1 # Seconds to wait between test requests CONN_ID="mcp_test" # WebSocket connection ID # Parse command line arguments usage() { echo "Usage: $0 [options] [test1,test2,...]" echo echo "Options:" echo " -p, --port PORT Specify WebSocket port (otherwise auto-detected)" echo " -l, --logs DIR Specify log directory (default: $LOG_DIR)" echo " -t, --timeout SEC Specify timeout in seconds (default: $TIMEOUT)" echo " -c, --compact Use compact display for tools list (name and type only)" echo " -h, --help Show this help message" echo echo "Available tests:" echo " all Run all tests (default)" echo " connect Test basic connection" echo " toolslist Test tools/list method" echo " toolinvoke Test tool invocation" echo " methods Test various method patterns" echo " selection Test selection notifications" echo echo "Example: $0 toolslist,connect" echo exit 1 } TESTS_TO_RUN=() COMPACT_VIEW=false while [[ $# -gt 0 ]]; do case $1 in -p | --port) WEBSOCKET_PORT="$2" shift 2 ;; -l | --logs) LOG_DIR="$2" shift 2 ;; -t | --timeout) TIMEOUT="$2" shift 2 ;; -c | --compact) COMPACT_VIEW=true shift ;; -h | --help) usage ;; *) # Parse comma-separated list of tests IFS=',' read -ra TESTS <<<"$1" for test in "${TESTS[@]}"; do TESTS_TO_RUN+=("$test") done shift ;; esac done # If no tests specified, run all if [ ${#TESTS_TO_RUN[@]} -eq 0 ]; then TESTS_TO_RUN=("all") fi mkdir -p "$LOG_DIR" # Get WebSocket port if not provided if [ -z "$WEBSOCKET_PORT" ]; then WEBSOCKET_PORT=$(find_claude_lockfile) fi echo "Using WebSocket port: $WEBSOCKET_PORT" echo "Logs will be stored in: $LOG_DIR" echo ############################################################ # Test Functions ############################################################ # Callback function to handle selection_changed notifications handle_selection_changed() { local message="$1" local count="$2" echo "📝 Received selection_changed notification #$count (this is normal and will be ignored)" } # Test function for basic connection test_connection() { local log_file="$LOG_DIR/connection_test.jsonl" local pretty_log="$LOG_DIR/connection_test_pretty.txt" echo "=== Running Connection Test ===" echo "Logs: $log_file" echo # Clear previous log files true >"$log_file" true >"$pretty_log" # Create WebSocket URL local ws_url="ws://127.0.0.1:$WEBSOCKET_PORT/" echo "Connecting to WebSocket server at $ws_url" # Establish persistent connection if ! ws_connect "$ws_url" "$CONN_ID"; then echo "❌ Failed to connect to WebSocket server" return 1 fi # Start a listener for selection_changed notifications ws_start_listener "$CONN_ID" handle_selection_changed "selection_changed" # Initialize connection with proper MCP lifecycle - must be single line local init_params init_params=$(ws_format_json '{"protocolVersion":"2025-03-26","capabilities":{"roots":{"listChanged":true},"sampling":{}},"clientInfo":{"name":"mcp-test-client","version":"1.0.0"}}') echo "Sending initialize request..." echo # Send initialize message and capture response local response response=$(ws_rpc_request "initialize" "$init_params" "connect-test" "$CONN_ID" "$TIMEOUT") # Send initialized notification after getting the response echo "Sending initialized notification..." ws_notify "notifications/initialized" "{}" "$CONN_ID" # Log the request and response local request request=$(ws_create_message "initialize" "$init_params" "connect-test") # Combine multiple redirects to the same files { echo "$request" echo "$response" } >>"$log_file" # Log with proper formatting { echo -e "\n--- Initialize Request ---" echo "$request" | jq '.' echo -e "\n--- Initialize Response ---" echo "$response" | jq '.' 2>/dev/null || echo "Invalid JSON: $response" # Log the initialized notification local init_notification init_notification=$(ws_create_message "notifications/initialized" "{}" "") echo -e "\n--- Initialized Notification ---" echo "$init_notification" | jq '.' } >>"$pretty_log" # Display and analyze the response echo "Response:" echo "$response" echo if echo "$response" | grep -q '"id":"connect-test"'; then echo "✅ Received response to our initialize request!" # Extract server info if present local server_info server_info=$(echo "$response" | jq -r '.result.serverInfo // "Not provided"' 2>/dev/null) local protocol protocol=$(echo "$response" | jq -r '.result.protocolVersion // "Not provided"' 2>/dev/null) # Extract server capabilities local capabilities capabilities=$(echo "$response" | jq -r '.result.capabilities // "None"' 2>/dev/null) echo "Server info: $server_info" echo "Protocol version: $protocol" echo "Server capabilities: $capabilities" else echo "⚠️ No direct response to our initialize request" if echo "$response" | grep -q '"method":"selection_changed"'; then echo "📝 Received a selection_changed notification instead (this is normal for VSCode extension)" fi fi echo "=== Connection Test Completed ===" echo } # Test function for tools/list method test_tools_list() { local log_file="$LOG_DIR/tools_list_test.jsonl" local pretty_log="$LOG_DIR/tools_list_test_pretty.txt" echo "=== Running Tools List Test ===" echo "Logs: $log_file" echo # Clear previous log files true >"$log_file" true >"$pretty_log" # Make sure we're using an existing connection if ! ws_is_connected "$CONN_ID"; then echo "⚠️ WebSocket connection not active. Establishing connection..." local ws_url="ws://127.0.0.1:$WEBSOCKET_PORT/" if ! ws_connect "$ws_url" "$CONN_ID"; then echo "❌ Failed to connect to WebSocket server" return 1 fi # Start a listener for selection_changed notifications ws_start_listener "$CONN_ID" handle_selection_changed "selection_changed" fi echo "Sending tools/list request..." echo # Send request and capture response local params params=$(ws_format_json '{}') local response response=$(ws_rpc_request "tools/list" "$params" "tools-list-test" "$CONN_ID" "$TIMEOUT") # Create the full request for logging local request request=$(ws_create_message "tools/list" "$params" "tools-list-test") # Log the request and response echo "$request" >>"$log_file" echo "$response" >>"$log_file" { echo -e "\n--- Tools List Request ---" echo "$request" | jq '.' echo -e "\n--- Tools List Response ---" echo "$response" | jq '.' 2>/dev/null || echo "Invalid JSON: $response" } >>"$pretty_log" # Display and analyze the response echo "Response received." if echo "$response" | grep -q '"error"'; then local error_code error_code=$(echo "$response" | jq -r '.error.code // "unknown"' 2>/dev/null) local error_message error_message=$(echo "$response" | jq -r '.error.message // "unknown"' 2>/dev/null) echo "❌ Error response: Code $error_code - $error_message" elif echo "$response" | grep -q '"result"'; then echo "✅ Successful response with tools list!" # Extract and count tools local tools_count tools_count=$(echo "$response" | jq '.result.tools | length' 2>/dev/null) echo "Found $tools_count tools in the response." # Format and display tools if [ "$COMPACT_VIEW" = "true" ]; then # Compact view - just name, type and required parameters echo "Tools (compact view):" # Using a safer approach for the compact view # Process each tool individually echo "$response" | jq -r '.result.tools[] | .name' | while read -r name; do # Output tool name echo " - $name" # Get tool schema schema=$(echo "$response" | jq -r --arg name "$name" '.result.tools[] | select(.name == $name) | .inputSchema // {}') has_props=$(echo "$schema" | jq 'has("properties")') if [ "$has_props" = "true" ]; then echo " Parameters:" # Get the keys and required list required_list=$(echo "$schema" | jq -r '.required // []') keys=$(echo "$schema" | jq -r '.properties | keys[]') for key in $keys; do # Get the type type=$(echo "$schema" | jq -r --arg key "$key" '.properties[$key].type // "any"') # Check if required is_required="" if echo "$required_list" | jq -r 'contains(["'"$key"'"])' | grep -q "true"; then is_required=" [REQUIRED]" fi # Output parameter echo " - $key ($type)$is_required" done else echo " Parameters: None" fi done else # Detailed view # Create helper function to format tool details format_tool_details() { local tool=$1 local name name=$(echo "$tool" | jq -r '.name') local desc desc=$(echo "$tool" | jq -r '.description // ""') # Print tool name and description (truncated if too long) echo " - $name" if [ -n "$desc" ]; then # Truncate description if longer than 70 chars if [ ${#desc} -gt 70 ]; then echo " Description: ${desc:0:67}..." else echo " Description: $desc" fi fi # Get parameters - look for inputSchema which is where parameters actually are local schema schema=$(echo "$tool" | jq -r '.inputSchema // {}') local has_props has_props=$(echo "$schema" | jq 'has("properties")') if [ "$has_props" = "true" ]; then echo " Parameters:" # Get required fields local required_fields required_fields=$(echo "$schema" | jq -r '.required // []') # Process each property - get keys first, then look up each property local keys keys=$(echo "$schema" | jq -r '.properties | keys[]') for key in $keys; do # Declare variables separately to avoid masking return values local type type=$(echo "$schema" | jq -r ".properties[\"$key\"].type // \"any\"") local param_desc param_desc=$(echo "$schema" | jq -r ".properties[\"$key\"].description // \"\"") # Check if required local is_required="" if echo "$required_fields" | jq -r 'contains(["'"$key"'"])' | grep -q "true"; then is_required=" [REQUIRED]" fi # Format parameter information echo " - $key ($type)$is_required" if [ -n "$param_desc" ]; then # Truncate description if longer than 60 chars if [ ${#param_desc} -gt 60 ]; then echo " Description: ${param_desc:0:57}..." else echo " Description: $param_desc" fi fi done # end of for key loop else echo " Parameters: None" fi echo } # Format and display tools echo "Tools and their parameters (detailed view):" echo "$response" | jq -r '.result.tools[] | @json' | while read -r tool; do format_tool_details "$tool" done fi elif echo "$response" | grep -q '"method":"selection_changed"'; then echo "⚠️ Received selection_changed notification instead of response" else echo "⚠️ Unexpected response format" fi echo "=== Tools List Test Completed ===" echo echo "For full details, see: $pretty_log" echo } # Test function for tool invocation test_tool_invoke() { local log_file="$LOG_DIR/tool_invoke_test.jsonl" local pretty_log="$LOG_DIR/tool_invoke_test_pretty.txt" echo "=== Running Tool Invocation Test ===" echo "Logs: $log_file" echo # Clear previous log files true >"$log_file" true >"$pretty_log" # Make sure we're using an existing connection if ! ws_is_connected "$CONN_ID"; then echo "⚠️ WebSocket connection not active. Establishing connection..." local ws_url="ws://127.0.0.1:$WEBSOCKET_PORT/" if ! ws_connect "$ws_url" "$CONN_ID"; then echo "❌ Failed to connect to WebSocket server" return 1 fi # Start a listener for selection_changed notifications ws_start_listener "$CONN_ID" handle_selection_changed "selection_changed" fi # Define test cases for tool invocation local test_cases=( # Format: "method params id" # Note: params need to be properly escaped for shell parsing but will be formatted with ws_format_json "tools/call '{\"name\":\"getCurrentSelection\",\"arguments\":{}}' tools-call-1" "tools/call '{\"name\":\"getWorkspaceFolders\",\"arguments\":{}}' tools-call-2" "tools/list '{}' direct-2" ) echo "Testing tool invocations with various formats..." echo for test_case in "${test_cases[@]}"; do read -r method params id <<<"$test_case" echo "=== Testing: $method (ID: $id) ===" # Format params params=$(ws_format_json "$params") # Send request and capture response local response response=$(ws_rpc_request "$method" "$params" "$id" "$CONN_ID" "$TIMEOUT") # Create full request for logging local request request=$(ws_create_message "$method" "$params" "$id") echo "Request: $request" # Log the request and response echo "$request" >>"$log_file" echo "$response" >>"$log_file" { echo -e "\n--- Tool Invoke Request: $method (ID: $id) ---" echo "$request" | jq '.' echo -e "\n--- Tool Invoke Response: $method (ID: $id) ---" echo "$response" | jq '.' 2>/dev/null || echo "Invalid JSON: $response" } >>"$pretty_log" # Display and analyze the response if echo "$response" | grep -q '"error"'; then local error_code error_code=$(echo "$response" | jq -r '.error.code // "unknown"' 2>/dev/null) local error_message error_message=$(echo "$response" | jq -r '.error.message // "unknown"' 2>/dev/null) echo "❌ Error response: Code $error_code - $error_message" elif echo "$response" | grep -q '"result"'; then echo "✅ Successful response with result" elif echo "$response" | grep -q '"method":"selection_changed"'; then echo "⚠️ Received selection_changed notification instead of response" else echo "⚠️ Unexpected response format" fi echo "=== End Testing: $method ===" echo # Wait a bit before the next request sleep $WAIT_BETWEEN done echo "=== Tool Invocation Test Completed ===" echo echo "For full details, see: $pretty_log" echo } # Test function for method patterns test_methods() { local log_file="$LOG_DIR/methods_test.jsonl" local pretty_log="$LOG_DIR/methods_test_pretty.txt" echo "=== Running Method Patterns Test ===" echo "Logs: $log_file" echo # Clear previous log files true >"$log_file" true >"$pretty_log" # Make sure we're using an existing connection if ! ws_is_connected "$CONN_ID"; then echo "⚠️ WebSocket connection not active. Establishing connection..." local ws_url="ws://127.0.0.1:$WEBSOCKET_PORT/" if ! ws_connect "$ws_url" "$CONN_ID"; then echo "❌ Failed to connect to WebSocket server" return 1 fi # Start a listener for selection_changed notifications ws_start_listener "$CONN_ID" handle_selection_changed "selection_changed" fi # Define test cases for different method patterns local test_cases=( # Format: "method params id description" # Note: params will be formatted with ws_format_json "tools/list '{}' methods-1 'Standard tools/list method'" "mcp.tools.list '{}' methods-2 'MCP prefix style'" "$/tools.list '{}' methods-3 'JSON-RPC style'" "listTools '{}' methods-4 'Direct method name'" ) echo "Testing various method patterns..." echo for test_case in "${test_cases[@]}"; do # Parse the test case read -r method params id description <<<"$test_case" echo "=== Testing method: $method ($description) ===" # Format params properly params=$(ws_format_json "$params") # Send request and capture response using our persistent connection local response response=$(ws_rpc_request "$method" "$params" "$id" "$CONN_ID" "$TIMEOUT") # Create full request for logging local request request=$(ws_create_message "$method" "$params" "$id") echo "Request: $request" # Log the request and response echo "$request" >>"$log_file" echo "$response" >>"$log_file" { echo -e "\n--- Method Request: $method (ID: $id) ---" echo "$request" | jq '.' 2>/dev/null echo -e "\n--- Method Response: $method (ID: $id) ---" echo "$response" | jq '.' 2>/dev/null || echo "Invalid JSON: $response" } >>"$pretty_log" # Display and analyze the response if echo "$response" | grep -q '"error"'; then local error_code error_code=$(echo "$response" | jq -r '.error.code // "unknown"' 2>/dev/null) local error_message error_message=$(echo "$response" | jq -r '.error.message // "unknown"' 2>/dev/null) echo "❌ Error response: Code $error_code - $error_message" elif echo "$response" | grep -q '"result"'; then echo "✅ Successful response with result" elif echo "$response" | grep -q '"method":"selection_changed"'; then echo "⚠️ Received selection_changed notification instead of response" else echo "⚠️ Unexpected response format" fi echo "=== End Testing: $method ===" echo # Wait a bit before the next request sleep $WAIT_BETWEEN done echo "=== Method Patterns Test Completed ===" echo echo "For full details, see: $pretty_log" echo } # Selection notification handler for listener handle_selection_test() { local message="$1" local count="$2" local log_file="$3" local pretty_log="$4" echo "📝 Received selection_changed notification #$count" # Extract some details local is_empty is_empty=$(echo "$message" | jq -r '.params.selection.isEmpty // "unknown"' 2>/dev/null) local file_path file_path=$(echo "$message" | jq -r '.params.filePath // "unknown"' 2>/dev/null) local text_length text_length=$(echo "$message" | jq -r '.params.text | length // 0' 2>/dev/null) echo " File: $file_path" echo " Empty selection: $is_empty" echo " Text length: $text_length characters" echo # Log to the files echo "$message" >>"$log_file" echo -e "\n--- Selection Changed Notification #$count ---" >>"$pretty_log" echo "$message" | jq '.' >>"$pretty_log" 2>/dev/null || echo "Invalid JSON: $message" >>"$pretty_log" } # Test function for selection notifications test_selection() { local log_file="$LOG_DIR/selection_test.jsonl" local pretty_log="$LOG_DIR/selection_test_pretty.txt" local listen_duration=10 # seconds to listen for selection events echo "=== Running Selection Notification Test ===" echo "Logs: $log_file" echo # Clear previous log files true >"$log_file" true >"$pretty_log" # Make sure we have a fresh connection for selection test local ws_url="ws://127.0.0.1:$WEBSOCKET_PORT/" # Clean up any existing connection if ws_is_connected "$CONN_ID"; then echo "Disconnecting existing WebSocket connection..." ws_disconnect "$CONN_ID" fi echo "Establishing connection to WebSocket server at $ws_url" if ! ws_connect "$ws_url" "$CONN_ID"; then echo "❌ Failed to connect to WebSocket server" return 1 fi echo "Listening for selection_changed events for $listen_duration seconds..." echo "Please make some selections in your editor during this time." echo # Set up selection_changed listener - we need a wrapper for the callback to include the log files selection_callback() { local message="$1" local count="$2" handle_selection_test "$message" "$count" "$log_file" "$pretty_log" } # Start the listener ws_start_listener "$CONN_ID" selection_callback "selection_changed" # Initialize connection with proper MCP lifecycle - must be single line local init_params init_params=$(ws_format_json '{"protocolVersion":"2025-03-26","capabilities":{"roots":{"listChanged":true},"sampling":{}},"clientInfo":{"name":"mcp-test-client","version":"1.0.0"}}') # Send initialize request ws_rpc_request "initialize" "$init_params" "selection-test" "$CONN_ID" "$TIMEOUT" >/dev/null # Send initialized notification ws_notify "notifications/initialized" "{}" "$CONN_ID" echo "Connection established. Waiting for $listen_duration seconds to collect selection events..." # Wait for the specified duration local start_time start_time=$(date +%s) local end_time=$((start_time + listen_duration)) # Display a countdown while [[ $(date +%s) -lt $end_time ]]; do local remaining=$((end_time - $(date +%s))) echo -ne "Collecting events: $remaining seconds remaining...\r" sleep 1 done echo -e "\nTime's up!" # Stop the listener ws_stop_listener "$CONN_ID" # Count recorded notifications from the log file local selection_count selection_count=$(grep -c '"method":"selection_changed"' "$log_file") echo "Received $selection_count selection_changed notifications." echo "=== Selection Notification Test Completed ===" echo echo "For full details, see: $pretty_log" echo } ############################################################ # Main execution ############################################################ # Run tests based on user input should_run_test() { local test="$1" # If "all" is in the list, run all tests for t in "${TESTS_TO_RUN[@]}"; do if [[ $t == "all" ]]; then return 0 fi done # Check if the specific test is in the list for t in "${TESTS_TO_RUN[@]}"; do if [[ $t == "$test" ]]; then return 0 fi done return 1 } # Run the specified tests if should_run_test "connect"; then test_connection fi if should_run_test "toolslist"; then test_tools_list fi if should_run_test "toolinvoke"; then test_tool_invoke fi if should_run_test "methods"; then test_methods fi if should_run_test "selection"; then test_selection fi # Clean up WebSocket connections if ws_is_connected "$CONN_ID"; then echo "Cleaning up WebSocket connection..." ws_disconnect "$CONN_ID" fi echo "All requested tests completed." echo "Log files are available in: $LOG_DIR" ================================================ FILE: scripts/research_messages.sh ================================================ #!/usr/bin/env bash # research_messages.sh - Script to analyze JSON-RPC messages from Claude Code VSCode extension # This script connects to a running Claude Code VSCode instance and logs all JSON-RPC messages # for analysis. set -e # Source the library SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./lib_claude.sh source "$SCRIPT_DIR/lib_claude.sh" # Configuration TIMEOUT=30 # How long to listen for messages (seconds) LOG_FILE="claude_messages.jsonl" # File to log all JSON-RPC messages PRETTY_LOG="claude_messages_pretty.txt" # File to log prettified messages WEBSOCKET_PORT="" # Will be detected automatically # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in -p | --port) WEBSOCKET_PORT="$2" shift 2 ;; -t | --timeout) TIMEOUT="$2" shift 2 ;; -l | --log) LOG_FILE="$2" shift 2 ;; *) echo "Unknown option: $1" echo "Usage: $0 [-p|--port PORT] [-t|--timeout SECONDS] [-l|--log LOGFILE]" exit 1 ;; esac done # Get WebSocket port if not provided if [ -z "$WEBSOCKET_PORT" ]; then # Use library function to find the port WEBSOCKET_PORT=$(find_claude_lockfile) echo "Found Claude Code running on port: $WEBSOCKET_PORT" fi # Create directory for logs LOG_DIR=$(dirname "$LOG_FILE") if [ ! -d "$LOG_DIR" ] && [ "$LOG_DIR" != "." ]; then mkdir -p "$LOG_DIR" fi # MCP connection message MCP_CONNECT='{ "jsonrpc": "2.0", "id": "1", "method": "mcp.connect", "params": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "clientInfo": { "name": "research-client", "version": "1.0.0" } } }' # Function to send a test message and see what happens send_test_message() { local method="$1" local params="$2" local id="$3" local message="{\"jsonrpc\":\"2.0\",\"id\":\"$id\",\"method\":\"$method\",\"params\":$params}" echo "Sending test message: $message" echo "$message" | websocat -n1 "ws://127.0.0.1:$WEBSOCKET_PORT/" echo } # Clear previous log files true >"$LOG_FILE" true >"$PRETTY_LOG" # Now that we have the port, display connection information echo "Connecting to WebSocket server at ws://127.0.0.1:$WEBSOCKET_PORT/" echo echo "Will listen for $TIMEOUT seconds and log messages to $LOG_FILE" echo "A prettified version will be written to $PRETTY_LOG" echo # Use websocat to connect and log all messages ( # First send the connection message echo "$MCP_CONNECT" # Keep the connection open sleep "$TIMEOUT" ) | websocat "ws://127.0.0.1:$WEBSOCKET_PORT/" | tee >(cat >"$LOG_FILE") | while IFS= read -r line; do # Print each message with timestamp echo "[$(date +"%H:%M:%S")] Received: $line" # Prettify JSON and append to pretty log file echo -e "\n--- Message at $(date +"%H:%M:%S") ---" >>"$PRETTY_LOG" echo "$line" | jq '.' >>"$PRETTY_LOG" 2>/dev/null || echo "Invalid JSON: $line" >>"$PRETTY_LOG" # Analyze message type if echo "$line" | grep -q '"method":'; then method=$(echo "$line" | jq -r '.method // "unknown"' 2>/dev/null) echo " → Method: $method" fi if echo "$line" | grep -q '"id":'; then id=$(echo "$line" | jq -r '.id // "unknown"' 2>/dev/null) echo " → ID: $id" # If this is a response to our connection message, try sending a test method if [ "$id" = "1" ]; then echo "Received connection response. Let's try some test methods..." sleep 2 # Test a tool invocation send_test_message "tools/call" '{"name":"getCurrentSelection","arguments":{}}' "2" # Try another tool invocation send_test_message "tools/call" '{"name":"getActiveFilePath","arguments":{}}' "3" # Try tools/list method send_test_message "tools/list" '{}' "4" # Try various method patterns send_test_message "listTools" '{}' "5" send_test_message "mcp.tools.list" '{}' "6" # Try pinging the server send_test_message "ping" '{}' "7" fi fi done echo echo "Listening completed after $TIMEOUT seconds." echo "Logged all messages to $LOG_FILE" echo "Prettified messages saved to $PRETTY_LOG" echo echo "Message summary:" # Generate a summary of message methods and IDs echo "Message methods found:" grep -o '"method":"[^"]*"' "$LOG_FILE" | sort | uniq -c | sort -nr echo echo "Message IDs found:" grep -o '"id":"[^"]*"' "$LOG_FILE" | sort | uniq -c | sort -nr # Now analyze the messages that were sent echo echo "Analyzing messages..." # Count number of selection_changed events selection_changed_count=$(grep -c '"method":"selection_changed"' "$LOG_FILE") echo "selection_changed notifications: $selection_changed_count" # Check if we received any tool responses tool_responses=$(grep -c '"id":"[23]"' "$LOG_FILE") echo "Tool responses: $tool_responses" echo echo "Research complete. See $PRETTY_LOG for detailed message content." ================================================ FILE: scripts/run_integration_tests_individually.sh ================================================ #!/bin/bash # Script to run integration tests individually to avoid plenary test_directory hanging # Each test file is run separately with test_file set -e echo "=== Running Integration Tests Individually ===" # Track overall results TOTAL_SUCCESS=0 TOTAL_FAILED=0 TOTAL_ERRORS=0 FAILED_FILES=() # Function to run a single test file run_test_file() { local test_file=$1 local basename basename=$(basename "$test_file") echo "" echo "Running: $basename" # Create a temporary file for output local temp_output temp_output=$(mktemp) # Run the test with timeout if timeout 30s nix develop .#ci -c nvim --headless -u tests/minimal_init.lua \ -c "lua require('plenary.test_harness').test_file('$test_file', {minimal_init = 'tests/minimal_init.lua'})" \ 2>&1 | tee "$temp_output"; then EXIT_CODE=0 else EXIT_CODE=$? fi # Parse results from output local clean_output clean_output=$(sed 's/\x1b\[[0-9;]*m//g' "$temp_output") local success_count success_count=$(echo "$clean_output" | grep -c "Success" || true) local failed_lines failed_lines=$(echo "$clean_output" | grep "Failed :" || echo "Failed : 0") local failed_count failed_count=$(echo "$failed_lines" | tail -1 | awk '{print $3}' || echo "0") local error_lines error_lines=$(echo "$clean_output" | grep "Errors :" || echo "Errors : 0") local error_count error_count=$(echo "$error_lines" | tail -1 | awk '{print $3}' || echo "0") # Update totals TOTAL_SUCCESS=$((TOTAL_SUCCESS + success_count)) TOTAL_FAILED=$((TOTAL_FAILED + failed_count)) TOTAL_ERRORS=$((TOTAL_ERRORS + error_count)) # Check if test failed if [[ $failed_count -gt 0 ]] || [[ $error_count -gt 0 ]] || { [[ $EXIT_CODE -ne 0 ]] && [[ $EXIT_CODE -ne 124 ]] && [[ $EXIT_CODE -ne 143 ]]; }; then FAILED_FILES+=("$basename") fi # Cleanup rm -f "$temp_output" } # Run each test file, skipping command_args_spec.lua which is known to hang for test_file in tests/integration/*_spec.lua; do if [[ $test_file == *"command_args_spec.lua" ]]; then echo "" echo "Skipping: $(basename "$test_file") (known to hang in CI)" continue fi run_test_file "$test_file" done # Summary echo "" echo "=========================================" echo "Integration Test Summary" echo "=========================================" echo "Total Success: $TOTAL_SUCCESS" echo "Total Failed: $TOTAL_FAILED" echo "Total Errors: $TOTAL_ERRORS" if [[ ${#FAILED_FILES[@]} -gt 0 ]]; then echo "" echo "Failed test files:" for file in "${FAILED_FILES[@]}"; do echo " - $file" done fi # Exit with appropriate code if [[ $TOTAL_FAILED -eq 0 ]] && [[ $TOTAL_ERRORS -eq 0 ]]; then echo "" echo "✅ All integration tests passed!" exit 0 else echo "" echo "❌ Some integration tests failed!" exit 1 fi ================================================ FILE: scripts/test_neovim_websocket.sh ================================================ #!/usr/bin/env bash # test_neovim_websocket.sh - Test script for the Claude Code Neovim WebSocket Server # This script launches Neovim with the plugin and runs MCP protocol tests against it. set -e # Source the library SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./lib_claude.sh source "$SCRIPT_DIR/lib_claude.sh" # Configuration TIMEOUT=10 # Maximum time to wait for server in seconds NVIM_INIT_FILE="/tmp/test_claudecode_init.lua" NVIM_BUFFER_FILE="/tmp/test_claudecode_buffer.txt" SAMPLE_FILE="/tmp/sample_file.lua" # File to open in Neovim for testing NVIM_PID="" LISTEN_DURATION=10 # How long to listen for messages (seconds) LOG_DIR="test_neovim_ws_logs" # Directory for log files TEST_MODE="all" # Default test mode WEBSOCKET_PORT="" # Will be detected from lock file # Parse command line arguments usage() { echo "Usage: $0 [options] [test-mode]" echo echo "Options:" echo " -t, --timeout SEC Timeout for server startup (default: $TIMEOUT)" echo " -l, --listen SEC Duration to listen for events (default: $LISTEN_DURATION)" echo " -d, --log-dir DIR Directory for logs (default: $LOG_DIR)" echo " -h, --help Show this help message" echo echo "Available test modes:" echo " all Run all tests (default)" echo " connect Test basic connection" echo " toolslist Test tools/list method" echo " selection Test selection notifications" echo echo "Example: $0 selection" echo exit 1 } while [[ $# -gt 0 ]]; do case $1 in -t | --timeout) TIMEOUT="$2" shift 2 ;; -l | --listen) LISTEN_DURATION="$2" shift 2 ;; -d | --log-dir) LOG_DIR="$2" shift 2 ;; -h | --help) usage ;; *) TEST_MODE="$1" shift ;; esac done # Setup trap to ensure Neovim is killed on exit cleanup() { if [ -n "$NVIM_PID" ] && ps -p "$NVIM_PID" >/dev/null; then echo "Cleaning up: Killing Neovim process $NVIM_PID" kill "$NVIM_PID" 2>/dev/null || kill -9 "$NVIM_PID" 2>/dev/null fi if [ -f "$NVIM_INIT_FILE" ]; then echo "Removing temporary init file" rm -f "$NVIM_INIT_FILE" fi if [ -f "$NVIM_BUFFER_FILE" ]; then echo "Removing temporary buffer file" rm -f "$NVIM_BUFFER_FILE" fi if [ -f "$SAMPLE_FILE" ]; then echo "Removing sample file" rm -f "$SAMPLE_FILE" fi echo "Cleanup completed" } # Register the cleanup function for various signals trap cleanup EXIT INT TERM # Create log directory mkdir -p "$LOG_DIR" # Create a sample Lua file for testing cat >"$SAMPLE_FILE" <<'EOL' -- Sample Lua file for testing selection notifications local M = {} ---Main function to perform a task ---@param input string The input string to process ---@return boolean success Whether the operation succeeded ---@return string|nil result The result of the operation function M.performTask(input) if type(input) ~= "string" then return false, "Input must be a string" end if #input == 0 then return false, "Input cannot be empty" end -- Process the input local result = input:upper() return true, result end ---Configuration options for the module M.config = { enabled = true, timeout = 1000, retries = 3, log_level = "info" } ---Initialize the module with given options ---@param opts table Configuration options ---@return boolean success Whether initialization succeeded function M.setup(opts) opts = opts or {} -- Merge options with defaults for k, v in pairs(opts) do M.config[k] = v end -- Additional setup logic here return true end return M EOL # Create a temporary init.lua file for testing cat >"$NVIM_INIT_FILE" <<'EOL' -- Minimal init.lua for testing the WebSocket server vim.cmd('set rtp+=.') require('claudecode').setup({ auto_start = true, log_level = "debug", track_selection = true }) -- Function to perform test operations once the server is running function perform_test_operations() -- Open the sample file vim.cmd('edit ' .. os.getenv('SAMPLE_FILE')) -- Make some selections to trigger selection_changed events vim.defer_fn(function() -- Select the performTask function vim.api.nvim_win_set_cursor(0, {9, 0}) vim.cmd('normal! V12j') vim.defer_fn(function() -- Move cursor to a specific position vim.api.nvim_win_set_cursor(0, {25, 10}) vim.defer_fn(function() -- Select the config table vim.api.nvim_win_set_cursor(0, {27, 0}) vim.cmd('normal! V5j') end, 500) end, 500) end, 1000) end -- Schedule test operations after a delay to ensure server is running vim.defer_fn(perform_test_operations, 2000) EOL # Function to find the most recently created lockfile # We use our own version here since we need to check the actual file on disk # to detect when the Neovim plugin creates a new lockfile find_newest_lockfile() { local lockfile_dir="$CLAUDE_LOCKFILE_DIR" if [ "$(uname)" = "Darwin" ]; then # macOS version find "$lockfile_dir" -name "*.lock" -type f -exec stat -f "%m %N" {} \; 2>/dev/null | sort -nr | head -n 1 | awk '{print $2}' else # Linux version find "$lockfile_dir" -name "*.lock" -type f -exec stat -c "%Y %n" {} \; 2>/dev/null | sort -nr | head -n 1 | awk '{print $2}' fi } # Record initial state of lockfiles echo "Checking for existing Claude IDE lock files..." INITIAL_NEWEST_LOCKFILE=$(find_newest_lockfile) INITIAL_MTIME=0 if [ -n "$INITIAL_NEWEST_LOCKFILE" ]; then if [ "$(uname)" = "Darwin" ]; then # macOS version INITIAL_MTIME=$(stat -f "%m" "$INITIAL_NEWEST_LOCKFILE") else # Linux version INITIAL_MTIME=$(stat -c "%Y" "$INITIAL_NEWEST_LOCKFILE") fi echo "Found existing lock file: $INITIAL_NEWEST_LOCKFILE (mtime: $INITIAL_MTIME)" else echo "No existing lock files found." fi # Start Neovim with the plugin in the background echo "Starting Neovim with Claude Code plugin..." SAMPLE_FILE="$SAMPLE_FILE" nvim -u "$NVIM_INIT_FILE" & NVIM_PID=$! # Wait for a new lockfile to appear echo "Waiting for WebSocket server to start..." PORT="" ELAPSED=0 while [[ -z $PORT && $ELAPSED -lt $TIMEOUT ]]; do NEWEST_LOCKFILE=$(find_newest_lockfile) if [ -n "$NEWEST_LOCKFILE" ]; then if [ "$(uname)" = "Darwin" ]; then # macOS version NEWEST_MTIME=$(stat -f "%m" "$NEWEST_LOCKFILE") else # Linux version NEWEST_MTIME=$(stat -c "%Y" "$NEWEST_LOCKFILE") fi # If this is a new lockfile that wasn't there before or is newer than our initial check if [ "$NEWEST_MTIME" -gt "$INITIAL_MTIME" ]; then LOCKFILE="$NEWEST_LOCKFILE" PORT=$(basename "$LOCKFILE" .lock) if [[ $PORT =~ ^[0-9]+$ ]]; then break else echo "Found lock file with invalid port format: $PORT" fi fi fi sleep 1 ((ELAPSED++)) echo "Waiting for server... ($ELAPSED/$TIMEOUT seconds)" done if [[ -z $PORT ]]; then echo "Error: Server did not start within $TIMEOUT seconds." exit 1 fi echo "Server started on port $PORT (lockfile: $LOCKFILE)" WEBSOCKET_PORT="$PORT" # MCP connection message MCP_CONNECT='{ "jsonrpc": "2.0", "id": "1", "method": "mcp.connect", "params": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "clientInfo": { "name": "neovim-test-client", "version": "1.0.0" } } }' # Test functions test_connection() { local log_file="$LOG_DIR/connection_test.jsonl" local pretty_log="$LOG_DIR/connection_test_pretty.txt" echo "=== Running Connection Test ===" echo "Logs: $log_file" echo # Clear previous log files true >"$log_file" true >"$pretty_log" echo "Connecting to WebSocket server at ws://127.0.0.1:$WEBSOCKET_PORT/" echo "Sending connection message..." echo # Send connection message and capture response local response response=$(echo "$MCP_CONNECT" | websocat -n1 "ws://127.0.0.1:$WEBSOCKET_PORT/") # Log the request and response echo "$MCP_CONNECT" >>"$log_file" echo "$response" >>"$log_file" { echo -e "\n--- Connection Request ---" echo "$MCP_CONNECT" | jq '.' echo -e "\n--- Connection Response ---" echo "$response" | jq '.' 2>/dev/null || echo "Invalid JSON: $response" } >>"$pretty_log" # Display and analyze the response echo "Response:" echo "$response" echo if echo "$response" | grep -q '"id":"1"'; then echo "✅ Received response to our connection request!" # Extract server info if present local server_info server_info=$(echo "$response" | jq -r '.result.serverInfo // "Not provided"' 2>/dev/null) local protocol protocol=$(echo "$response" | jq -r '.result.protocolVersion // "Not provided"' 2>/dev/null) echo "Server info: $server_info" echo "Protocol version: $protocol" else echo "⚠️ No direct response to our connection request" if echo "$response" | grep -q '"method":"selection_changed"'; then echo "📝 Received a selection_changed notification instead (this is normal)" fi fi echo "=== Connection Test Completed ===" echo } test_tools_list() { local log_file="$LOG_DIR/tools_list_test.jsonl" local pretty_log="$LOG_DIR/tools_list_test_pretty.txt" echo "=== Running Tools List Test ===" echo "Logs: $log_file" echo # Clear previous log files true >"$log_file" true >"$pretty_log" # Create tools/list request local request='{ "jsonrpc": "2.0", "id": "tools-list-test", "method": "tools/list", "params": {} }' echo "Connecting to WebSocket server at ws://127.0.0.1:$WEBSOCKET_PORT/" echo "Sending tools/list request..." echo # Send request and capture response local response response=$(echo "$request" | websocat -n1 "ws://127.0.0.1:$WEBSOCKET_PORT/") # Log the request and response echo "$request" >>"$log_file" echo "$response" >>"$log_file" { echo -e "\n--- Tools List Request ---" echo "$request" | jq '.' echo -e "\n--- Tools List Response ---" echo "$response" | jq '.' 2>/dev/null || echo "Invalid JSON: $response" } >>"$pretty_log" # Display and analyze the response echo "Response received." if echo "$response" | grep -q '"error"'; then local error_code error_code=$(echo "$response" | jq -r '.error.code // "unknown"' 2>/dev/null) local error_message error_message=$(echo "$response" | jq -r '.error.message // "unknown"' 2>/dev/null) echo "❌ Error response: Code $error_code - $error_message" elif echo "$response" | grep -q '"result"'; then echo "✅ Successful response with tools list!" # Extract and count tools local tools_count tools_count=$(echo "$response" | jq '.result.tools | length' 2>/dev/null) echo "Found $tools_count tools in the response." # List the tool names echo "Tool names:" echo "$response" | jq -r '.result.tools[].name' 2>/dev/null | sort | sed 's/^/ - /' elif echo "$response" | grep -q '"method":"selection_changed"'; then echo "⚠️ Received selection_changed notification instead of response" else echo "⚠️ Unexpected response format" fi echo "=== Tools List Test Completed ===" echo echo "For full details, see: $pretty_log" echo } test_selection() { local log_file="$LOG_DIR/selection_test.jsonl" local pretty_log="$LOG_DIR/selection_test_pretty.txt" echo "=== Running Selection Notification Test ===" echo "Logs: $log_file" echo # Clear previous log files true >"$log_file" true >"$pretty_log" echo "Connecting to WebSocket server at ws://127.0.0.1:$WEBSOCKET_PORT/" echo "Listening for selection_changed events for $LISTEN_DURATION seconds..." echo "Automatic selections will be made in the Neovim instance." echo # Connect and listen for messages ( # Send an initial message to establish connection echo '{"jsonrpc":"2.0","id":"selection-test","method":"mcp.connect","params":{"protocolVersion":"2024-11-05"}}' # Keep the connection open sleep "$LISTEN_DURATION" ) | websocat "ws://127.0.0.1:$WEBSOCKET_PORT/" | tee >(cat >"$log_file") | { # Process received messages local selection_count=0 while IFS= read -r line; do # Check if this is a selection_changed notification if echo "$line" | grep -q '"method":"selection_changed"'; then ((selection_count++)) echo "📝 Received selection_changed notification #$selection_count" # Extract some details local is_empty is_empty=$(echo "$line" | jq -r '.params.selection.isEmpty // "unknown"' 2>/dev/null) local file_path file_path=$(echo "$line" | jq -r '.params.filePath // "unknown"' 2>/dev/null) local text_length text_length=$(echo "$line" | jq -r '.params.text | length // 0' 2>/dev/null) echo " File: $file_path" echo " Empty selection: $is_empty" echo " Text length: $text_length characters" echo # Log to pretty log echo -e "\n--- Selection Changed Notification #$selection_count ---" >>"$pretty_log" echo "$line" | jq '.' >>"$pretty_log" 2>/dev/null || echo "Invalid JSON: $line" >>"$pretty_log" else echo "Received non-selection message:" echo "$line" | jq '.' 2>/dev/null || echo "$line" echo # Log to pretty log echo -e "\n--- Other Message ---" >>"$pretty_log" echo "$line" | jq '.' >>"$pretty_log" 2>/dev/null || echo "Invalid JSON: $line" >>"$pretty_log" fi done echo "Received $selection_count selection_changed notifications." } echo "=== Selection Notification Test Completed ===" echo echo "For full details, see: $pretty_log" echo } # Run selected tests run_tests() { case "$TEST_MODE" in "all") test_connection test_tools_list test_selection ;; "connect") test_connection ;; "toolslist") test_tools_list ;; "selection") test_selection ;; *) echo "Unknown test mode: $TEST_MODE" echo "Available modes: all, connect, toolslist, selection" exit 1 ;; esac } # Execute the tests run_tests echo "All tests completed." echo "Log files are available in: $LOG_DIR" # Cleanup will be handled by the trap on exit ================================================ FILE: scripts/test_opendiff.lua ================================================ #!/usr/bin/env lua -- Test script that mimics Claude Code CLI sending an openDiff tool call -- This helps automate testing of the openDiff blocking behavior local socket = require("socket") local json = require("json") or require("cjson") or require("dkjson") -- Configuration local HOST = "127.0.0.1" local PORT = nil -- Will discover from lock file local LOCK_FILE_PATH = os.getenv("HOME") .. "/.claude/ide/" -- Discover port from lock files local function discover_port() local handle = io.popen("ls " .. LOCK_FILE_PATH .. "*.lock 2>/dev/null") if not handle then print("❌ No lock files found in " .. LOCK_FILE_PATH) return nil end local result = handle:read("*a") handle:close() if result == "" then print("❌ No lock files found") return nil end -- Extract port from first lock file name local lock_file = result:match("([^\n]+)") local port = lock_file:match("(%d+)%.lock") if port then print("✅ Discovered port " .. port .. " from " .. lock_file) return tonumber(port) else print("❌ Could not parse port from lock file: " .. lock_file) return nil end end -- Read README.md content local function read_readme() local file = io.open("README.md", "r") if not file then print("❌ Could not read README.md - run this script from the project root") os.exit(1) end local content = file:read("*a") file:close() -- Simulate adding a license link (append at end) local modified_content = content .. "\n## License\n\n[MIT](LICENSE)\n" return content, modified_content end -- Create WebSocket handshake local function websocket_handshake(sock) local key = "dGhlIHNhbXBsZSBub25jZQ==" local request = string.format( "GET / HTTP/1.1\r\n" .. "Host: %s:%d\r\n" .. "Upgrade: websocket\r\n" .. "Connection: Upgrade\r\n" .. "Sec-WebSocket-Key: %s\r\n" .. "Sec-WebSocket-Version: 13\r\n" .. "\r\n", HOST, PORT, key ) sock:send(request) local response = sock:receive("*l") if not response or not response:match("101 Switching Protocols") then print("❌ WebSocket handshake failed") return false end -- Read remaining headers repeat local line = sock:receive("*l") until line == "" print("✅ WebSocket handshake successful") return true end -- Send WebSocket frame local function send_frame(sock, payload) local len = #payload local frame = string.char(0x81) -- Text frame, FIN=1 if len < 126 then frame = frame .. string.char(len) elseif len < 65536 then frame = frame .. string.char(126) .. string.char(math.floor(len / 256)) .. string.char(len % 256) else error("Payload too large") end frame = frame .. payload sock:send(frame) end -- Main test function local function test_opendiff() print("🧪 Starting openDiff automation test...") -- Step 1: Discover port PORT = discover_port() if not PORT then print("❌ Make sure Neovim with claudecode.nvim is running first") os.exit(1) end -- Step 2: Read README content local old_content, new_content = read_readme() print("✅ Loaded README.md (" .. #old_content .. " chars)") -- Step 3: Connect to WebSocket local sock = socket.tcp() sock:settimeout(5) local success, err = sock:connect(HOST, PORT) if not success then print("❌ Could not connect to " .. HOST .. ":" .. PORT .. " - " .. (err or "unknown error")) os.exit(1) end print("✅ Connected to WebSocket server") -- Step 4: WebSocket handshake if not websocket_handshake(sock) then os.exit(1) end -- Step 5: Send openDiff tool call local tool_call = { jsonrpc = "2.0", id = 1, method = "tools/call", params = { name = "openDiff", arguments = { old_file_path = os.getenv("PWD") .. "/README.md", new_file_path = os.getenv("PWD") .. "/README.md", new_file_contents = new_content, tab_name = "✻ [Test] README.md (automated) ⧉", }, }, } local json_message = json.encode(tool_call) print("📤 Sending openDiff tool call...") send_frame(sock, json_message) -- Step 6: Wait for response with timeout print("⏳ Waiting for response (should block until user interaction)...") sock:settimeout(30) -- 30 second timeout local response = sock:receive("*l") if response then print("📥 Received immediate response (BAD - should block):") print(response) else print("✅ No immediate response - tool is properly blocking!") print("👉 Now go to Neovim and interact with the diff (save or close)") print("👉 Press Ctrl+C here when done testing") -- Keep listening for the eventual response sock:settimeout(0) -- Non-blocking repeat local data = sock:receive("*l") if data then print("📥 Final response received:") print(data) break end socket.sleep(0.1) until false end sock:close() end -- Check dependencies if not socket then print("❌ luasocket not found. Install with: luarocks install luasocket") os.exit(1) end if not json then print("❌ JSON library not found. Install with: luarocks install dkjson") os.exit(1) end -- Run the test test_opendiff() ================================================ FILE: scripts/test_opendiff_simple.sh ================================================ #!/bin/bash # Simple openDiff test using websocat (if available) or curl # This script sends the same tool call that Claude Code would send set -e echo "🧪 Testing openDiff tool behavior..." # Find the port from lock file LOCK_DIR="$HOME/.claude/ide" if [[ ! -d $LOCK_DIR ]]; then echo "❌ Lock directory not found: $LOCK_DIR" echo " Make sure Neovim with claudecode.nvim is running" exit 1 fi LOCK_FILE=$(find "$LOCK_DIR" -name "*.lock" -type f 2>/dev/null | head -1) if [[ -z $LOCK_FILE ]]; then echo "❌ No lock files found in $LOCK_DIR" echo " Make sure Neovim with claudecode.nvim is running" exit 1 fi PORT=$(basename "$LOCK_FILE" .lock) echo "✅ Found port: $PORT" # Read README.md if [[ ! -f "README.md" ]]; then echo "❌ README.md not found - run this script from project root" exit 1 fi echo "✅ Found README.md" # Create modified content (add license section) NEW_CONTENT=$(cat README.md && echo -e "\n## License\n\n[MIT](LICENSE)") # Get absolute path ABS_PATH="$(pwd)/README.md" # Create JSON-RPC message JSON_MESSAGE=$( cat </dev/null 2>&1; then echo "Using websocat..." echo "$JSON_MESSAGE" | timeout 30s websocat "ws://127.0.0.1:$PORT" || { if [[ $? -eq 124 ]]; then echo "✅ Tool blocked for 30s (good behavior!)" echo "👉 Check Neovim for the diff view" else echo "❌ Connection failed" fi } elif command -v wscat >/dev/null 2>&1; then echo "Using wscat..." echo "$JSON_MESSAGE" | timeout 30s wscat -c "ws://127.0.0.1:$PORT" || { if [[ $? -eq 124 ]]; then echo "✅ Tool blocked for 30s (good behavior!)" echo "👉 Check Neovim for the diff view" else echo "❌ Connection failed" fi } else echo "❌ No WebSocket client found (websocat or wscat needed)" echo " Install with: brew install websocat" echo " Or: npm install -g wscat" exit 1 fi echo "✅ Test completed" ================================================ FILE: scripts/websocat.sh ================================================ #!/usr/bin/env bash CLAUDE_LIB_DIR="$(dirname "$(realpath "$0")")" # shellcheck source=./lib_claude.sh source "$CLAUDE_LIB_DIR/lib_claude.sh" websocat "$(get_claude_ws_url)" # Tools list # { "jsonrpc": "2.0", "id": "tools-list-test", "method": "tools/list", "params": {} } # # {"jsonrpc":"2.0","id":"direct-1","method":"getCurrentSelection","params":{}} # # { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "getCurrentSelection", "arguments": { } } } ================================================ FILE: tests/busted_setup.lua ================================================ -- Test setup for busted -- Create mock vim API if we're running tests outside of Neovim if not _G.vim then _G.vim = require("tests.mocks.vim") end -- Ensure vim global is accessible _G.vim = _G.vim or {} -- Setup test globals _G.assert = require("luassert") -- Helper function to verify expectations _G.expect = function(value) return { to_be = function(expected) assert.are.equal(expected, value) end, to_be_nil = function() assert.is_nil(value) end, to_be_true = function() assert.is_true(value) end, to_be_false = function() assert.is_false(value) end, to_be_table = function() assert.is_table(value) end, to_be_string = function() assert.is_string(value) end, to_be_function = function() assert.is_function(value) end, to_be_boolean = function() assert.is_boolean(value) end, to_be_at_least = function(expected) assert.is_true(value >= expected) end, to_have_key = function(key) assert.is_table(value) assert.not_nil(value[key]) end, -- to_contain was here, moved to _G.assert_contains not_to_be_nil = function() assert.is_not_nil(value) end, -- not_to_contain was here, moved to _G.assert_not_contains to_be_truthy = function() assert.is_truthy(value) end, } end _G.assert_contains = function(actual_value, expected_pattern) if type(actual_value) == "string" then if type(expected_pattern) ~= "string" then error( "assert_contains expected a string pattern for a string actual_value, but expected_pattern was type: " .. type(expected_pattern) ) end assert.is_true( string.find(actual_value, expected_pattern, 1, true) ~= nil, "Expected string '" .. actual_value .. "' to contain '" .. expected_pattern .. "'" ) elseif type(actual_value) == "table" then local found = false for _, v in ipairs(actual_value) do if v == expected_pattern then found = true break end end assert.is_true(found, "Expected table to contain value: " .. tostring(expected_pattern)) else error("assert_contains can only be used with string or table actual_values, got type: " .. type(actual_value)) end end _G.assert_not_contains = function(actual_value, expected_pattern) if type(actual_value) == "string" then if type(expected_pattern) ~= "string" then error( "assert_not_contains expected a string pattern for a string actual_value, but expected_pattern was type: " .. type(expected_pattern) ) end assert.is_true( string.find(actual_value, expected_pattern, 1, true) == nil, "Expected string '" .. actual_value .. "' NOT to contain '" .. expected_pattern .. "'" ) elseif type(actual_value) == "table" then local found = false for _, v in ipairs(actual_value) do if v == expected_pattern then found = true break end end assert.is_false(found, "Expected table NOT to contain value: " .. tostring(expected_pattern)) else error("assert_not_contains can only be used with string or table actual_values, got type: " .. type(actual_value)) end end -- JSON encoding/decoding helpers for tests _G.json_encode = function(data) if type(data) == "table" then local parts = {} local is_array = true -- Check if it's an array (all numeric, positive keys) or an object for k, _ in pairs(data) do if type(k) ~= "number" or k <= 0 or math.floor(k) ~= k then is_array = false break end end if is_array then table.insert(parts, "[") for i, v in ipairs(data) do if i > 1 then table.insert(parts, ",") end table.insert(parts, _G.json_encode(v)) end table.insert(parts, "]") else table.insert(parts, "{") local first = true for k, v in pairs(data) do if not first then table.insert(parts, ",") end first = false -- Handle special Lua keywords as object keys local key_str = tostring(k) if key_str == "end" then table.insert(parts, '["end"]:') else table.insert(parts, '"' .. key_str .. '":') end table.insert(parts, _G.json_encode(v)) end table.insert(parts, "}") end return table.concat(parts) elseif type(data) == "string" then -- Handle escape sequences properly local escaped = data :gsub("\\", "\\\\") -- Escape backslashes first :gsub('"', '\\"') -- Escape quotes :gsub("\n", "\\n") -- Escape newlines :gsub("\r", "\\r") -- Escape carriage returns :gsub("\t", "\\t") -- Escape tabs return '"' .. escaped .. '"' elseif type(data) == "boolean" then return data and "true" or "false" elseif type(data) == "number" then return tostring(data) else return "null" end end -- Simple JSON decoder for test purposes _G.json_decode = function(str) if not str or str == "" then return nil end local pos = 1 local function skip_whitespace() while pos <= #str and str:sub(pos, pos):match("%s") do pos = pos + 1 end end local function parse_value() skip_whitespace() if pos > #str then return nil end local char = str:sub(pos, pos) if char == '"' then -- Parse string pos = pos + 1 local start = pos while pos <= #str and str:sub(pos, pos) ~= '"' do if str:sub(pos, pos) == "\\" then pos = pos + 1 end pos = pos + 1 end local value = str :sub(start, pos - 1) :gsub('\\"', '"') -- Unescape quotes :gsub("\\\\", "\\") -- Unescape backslashes :gsub("\\n", "\n") -- Unescape newlines :gsub("\\r", "\r") -- Unescape carriage returns :gsub("\\t", "\t") -- Unescape tabs pos = pos + 1 return value elseif char == "{" then -- Parse object pos = pos + 1 local obj = {} skip_whitespace() if pos <= #str and str:sub(pos, pos) == "}" then pos = pos + 1 return obj end while true do skip_whitespace() -- Parse key if str:sub(pos, pos) ~= '"' and str:sub(pos, pos) ~= "[" then break end local key if str:sub(pos, pos) == '"' then key = parse_value() elseif str:sub(pos, pos) == "[" then -- Handle bracket notation like ["end"] pos = pos + 2 -- skip [" local start = pos while pos <= #str and str:sub(pos, pos) ~= '"' do pos = pos + 1 end key = str:sub(start, pos - 1) pos = pos + 2 -- skip "] else break end skip_whitespace() if pos > #str or str:sub(pos, pos) ~= ":" then break end pos = pos + 1 -- Parse value local value = parse_value() obj[key] = value skip_whitespace() if pos > #str then break end if str:sub(pos, pos) == "}" then pos = pos + 1 break elseif str:sub(pos, pos) == "," then pos = pos + 1 else break end end return obj elseif char == "[" then -- Parse array pos = pos + 1 local arr = {} skip_whitespace() if pos <= #str and str:sub(pos, pos) == "]" then pos = pos + 1 return arr end while true do table.insert(arr, parse_value()) skip_whitespace() if pos > #str then break end if str:sub(pos, pos) == "]" then pos = pos + 1 break elseif str:sub(pos, pos) == "," then pos = pos + 1 else break end end return arr elseif char:match("%d") or char == "-" then -- Parse number local start = pos if char == "-" then pos = pos + 1 end while pos <= #str and str:sub(pos, pos):match("%d") do pos = pos + 1 end if pos <= #str and str:sub(pos, pos) == "." then pos = pos + 1 while pos <= #str and str:sub(pos, pos):match("%d") do pos = pos + 1 end end return tonumber(str:sub(start, pos - 1)) elseif str:sub(pos, pos + 3) == "true" then pos = pos + 4 return true elseif str:sub(pos, pos + 4) == "false" then pos = pos + 5 return false elseif str:sub(pos, pos + 3) == "null" then pos = pos + 4 return nil else return nil end end return parse_value() end -- Return true to indicate setup was successful return { json_encode = _G.json_encode, json_decode = _G.json_decode, } ================================================ FILE: tests/config_test.lua ================================================ -- Simple config module tests that don't rely on the vim API _G.vim = { ---@type vim_global_api schedule_wrap = function(fn) return fn end, deepcopy = function(t) -- Basic deepcopy implementation for testing purposes local copy = {} for k, v in pairs(t) do if type(v) == "table" then copy[k] = _G.vim.deepcopy(v) else copy[k] = v end end return copy end, notify = function(_, _, _) end, log = { levels = { NONE = 0, ERROR = 1, WARN = 2, INFO = 3, DEBUG = 4, TRACE = 5, }, }, o = { ---@type vim_options_table columns = 80, lines = 24, }, bo = setmetatable({}, { -- Mock for vim.bo and vim.bo[bufnr] __index = function(t, k) if type(k) == "number" then if not t[k] then t[k] = {} ---@type vim_buffer_options_table end return t[k] end return nil end, __newindex = function(t, k, v) if type(k) == "number" then -- For mock simplicity, allows direct setting for vim.bo[bufnr].opt = val or similar assignments. if not t[k] then t[k] = {} end rawset(t[k], v) -- Assuming v is the option name if k is bufnr, this is simplified else rawset(t, k, v) end end, }), ---@type vim_bo_proxy diagnostic = { ---@type vim_diagnostic_module get = function() return {} end, -- Add other vim.diagnostic functions as needed for these tests }, empty_dict = function() return {} end, tbl_deep_extend = function(behavior, ...) local result = {} local tables = { ... } for _, tbl in ipairs(tables) do for k, v in pairs(tbl) do if type(v) == "table" and type(result[k]) == "table" then result[k] = _G.vim.tbl_deep_extend(behavior, result[k], v) else result[k] = v end end end return result end, cmd = function() end, ---@type fun(command: string):nil api = {}, ---@type table fn = { ---@type vim_fn_table mode = function() return "n" end, delete = function(_, _) return 0 end, filereadable = function(_) return 1 end, fnamemodify = function(fname, _) return fname end, expand = function(s, _) return s end, getcwd = function() return "/mock/cwd" end, mkdir = function(_, _, _) return 1 end, buflisted = function(_) return 1 end, bufname = function(_) return "mockbuffer" end, bufnr = function(_) return 1 end, win_getid = function() return 1 end, win_gotoid = function(_) return true end, line = function(_) return 1 end, col = function(_) return 1 end, virtcol = function(_) return 1 end, getpos = function(_) return { 0, 1, 1, 0 } end, setpos = function(_, _) return true end, tempname = function() return "/tmp/mocktemp" end, globpath = function(_, _) return "" end, termopen = function(_, _) return 0 end, stdpath = function(_) return "/mock/stdpath" end, json_encode = function(_) return "{}" end, json_decode = function(_) return {} end, }, fs = { remove = function() end }, ---@type vim_fs_module } describe("Config module", function() local config setup(function() -- Reset the module to ensure a clean state for each test package.loaded["claudecode.config"] = nil config = require("claudecode.config") end) it("should have default values", function() assert(type(config.defaults) == "table") assert(type(config.defaults.port_range) == "table") assert(type(config.defaults.port_range.min) == "number") assert(type(config.defaults.port_range.max) == "number") assert(type(config.defaults.auto_start) == "boolean") assert(type(config.defaults.log_level) == "string") assert(type(config.defaults.track_selection) == "boolean") end) it("should apply and validate user configuration", function() local user_config = { terminal_cmd = "toggleterm", log_level = "debug", track_selection = false, models = { { name = "Claude Opus 4 (Latest)", value = "claude-opus-4-20250514" }, { name = "Claude Sonnet 4 (Latest)", value = "claude-sonnet-4-20250514" }, }, } local success, final_config = pcall(function() return config.apply(user_config) end) assert(success == true) assert(final_config.env ~= nil) -- Should inherit default empty table assert(type(final_config.env) == "table") end) it("should merge user config with defaults", function() local user_config = { auto_start = true, log_level = "debug", } local merged_config = config.apply(user_config) assert(merged_config.auto_start == true) assert("debug" == merged_config.log_level) assert(config.defaults.port_range.min == merged_config.port_range.min) assert(config.defaults.track_selection == merged_config.track_selection) end) end) ================================================ FILE: tests/init.lua ================================================ -- Test runner for Claude Code Neovim integration local M = {} -- Run all tests function M.run() -- Set up minimal test environment require("tests.helpers.setup")() -- Discover and run all tests M.run_unit_tests() M.run_component_tests() M.run_integration_tests() -- Report results M.report_results() end -- Run unit tests function M.run_unit_tests() -- Run all unit tests require("tests.unit.config_spec") require("tests.unit.server_spec") require("tests.unit.tools_spec") require("tests.unit.selection_spec") require("tests.unit.lockfile_spec") end -- Run component tests function M.run_component_tests() -- Run all component tests require("tests.component.server_spec") require("tests.component.tools_spec") end -- Run integration tests function M.run_integration_tests() -- Run all integration tests require("tests.integration.e2e_spec") end -- Report test results function M.report_results() -- Print test summary print("All tests completed!") -- In a real implementation, this would report -- detailed test statistics end return M ================================================ FILE: tests/lockfile_test.lua ================================================ -- Tests for lockfile module -- Load mock vim if needed local real_vim = _G.vim if not _G.vim then -- Create a basic vim mock _G.vim = { ---@type vim_global_api schedule_wrap = function(fn) return fn end, deepcopy = function(t) -- Basic deepcopy for testing local copy = {} for k, v in pairs(t) do if type(v) == "table" then copy[k] = _G.vim.deepcopy(v) else copy[k] = v end end return copy end, cmd = function() end, ---@type fun(command: string):nil api = {}, ---@type table fs = { remove = function() end }, ---@type vim_fs_module fn = { ---@type vim_fn_table expand = function(path) -- Use a temp directory that actually exists local temp_dir = os.getenv("TMPDIR") or "/tmp" return select(1, path:gsub("~", temp_dir .. "/claude_test")) end, -- Add other vim.fn mocks as needed by lockfile tests -- For now, only adding what's explicitly used or causing major type issues filereadable = function(path) -- Check if file actually exists local file = io.open(path, "r") if file then file:close() return 1 else return 0 end end, fnamemodify = function(fname, _) return fname end, delete = function(_, _) return 0 end, mode = function() return "n" end, buflisted = function(_) return 0 end, bufname = function(_) return "" end, bufnr = function(_) return 0 end, win_getid = function() return 0 end, win_gotoid = function(_) return false end, line = function(_) return 0 end, col = function(_) return 0 end, virtcol = function(_) return 0 end, getpos = function(_) return { 0, 0, 0, 0 } end, setpos = function(_, _) return false end, tempname = function() return "" end, globpath = function(_, _) return "" end, stdpath = function(_) return "" end, json_encode = function(_) return "{}" end, json_decode = function(_) return {} end, -- getcwd is defined later in setup, so no need to mock it here initially -- mkdir is defined later in setup -- getpid is defined later in setup getcwd = function() return "/mock/cwd" end, mkdir = function() return 1 end, getpid = function() return 12345 end, termopen = function(_, _) return 0 end, }, notify = function(_, _, _) end, log = { levels = { NONE = 0, ERROR = 1, WARN = 2, INFO = 3, DEBUG = 4, TRACE = 5, }, }, json = { encode = function(obj) -- Simple JSON encoding for testing if type(obj) == "table" then local pairs_array = {} for k, v in pairs(obj) do local key_str = '"' .. tostring(k) .. '"' local val_str if type(v) == "string" then val_str = '"' .. v .. '"' elseif type(v) == "number" then val_str = tostring(v) elseif type(v) == "table" then -- Simple array encoding local items = {} for _, item in ipairs(v) do table.insert(items, '"' .. tostring(item) .. '"') end val_str = "[" .. table.concat(items, ",") .. "]" else val_str = '"' .. tostring(v) .. '"' end table.insert(pairs_array, key_str .. ":" .. val_str) end return "{" .. table.concat(pairs_array, ",") .. "}" else return '"' .. tostring(obj) .. '"' end end, decode = function(json_str) -- Very basic JSON parsing for test purposes if json_str:match("^%s*{.*}%s*$") then local result = {} -- Extract key-value pairs - this is very basic for key, value in json_str:gmatch('"([^"]+)"%s*:%s*"([^"]*)"') do result[key] = value end for key, value in json_str:gmatch('"([^"]+)"%s*:%s*(%d+)') do result[key] = tonumber(value) end return result end return {} end, }, lsp = {}, -- Existing lsp mock part o = { ---@type vim_options_table columns = 80, lines = 24, }, bo = setmetatable({}, { -- Mock for vim.bo and vim.bo[bufnr] __index = function(t, k) if type(k) == "number" then -- vim.bo[bufnr] accessed, return a new proxy table for this buffer if not t[k] then t[k] = {} ---@type vim_buffer_options_table end return t[k] end -- vim.bo.option_name (global buffer option) return nil -- Return nil or a default mock value if needed end, -- REMOVED COMMA from here (was after 'end') -- __newindex can be added here if setting options is needed for tests -- e.g., __newindex = function(t, k, v) rawset(t, k, v) end, }), ---@type vim_bo_proxy diagnostic = { ---@type vim_diagnostic_module get = function() return {} end, -- Add other vim.diagnostic functions as needed for tests }, empty_dict = function() return {} end, } -- This is the closing brace for _G.vim table end describe("Lockfile Module", function() local lockfile -- Save original vim functions/tables (not used in this test but kept for reference) -- luacheck: ignore local orig_vim = _G.vim local orig_fn_getcwd = vim.fn.getcwd local orig_lsp = vim.lsp -- luacheck: no ignore -- Create a mock for testing LSP client resolution local create_mock_env = function(api_version) -- Configure mock based on API version local mock_lsp = {} -- Test workspace folders data local test_workspace_data = { { config = { workspace_folders = { { uri = "file:///mock/folder1" }, { uri = "file:///mock/folder2" }, }, }, }, } if api_version == "current" then -- Neovim 0.11+ API (get_clients) mock_lsp.get_clients = function() return test_workspace_data end elseif api_version == "legacy" then -- Neovim 0.8-0.10 API (get_active_clients) mock_lsp.get_active_clients = function() return test_workspace_data end end -- Apply mock vim.lsp = mock_lsp end setup(function() -- Mock required vim functions before loading the module vim.fn.getcwd = function() return "/mock/cwd" end -- Create test directory local temp_dir = os.getenv("TMPDIR") or "/tmp" local test_dir = temp_dir .. "/claude_test/.claude/ide" os.execute("mkdir -p '" .. test_dir .. "'") -- Load the lockfile module for all tests package.loaded["claudecode.lockfile"] = nil -- Clear any previous requires lockfile = require("claudecode.lockfile") end) teardown(function() -- Clean up test files local temp_dir = os.getenv("TMPDIR") or "/tmp" local test_dir = temp_dir .. "/claude_test" os.execute("rm -rf '" .. test_dir .. "'") -- Restore original vim if real_vim then _G.vim = real_vim end end) describe("get_workspace_folders()", function() before_each(function() -- Ensure consistent path vim.fn.getcwd = function() return "/mock/cwd" end end) after_each(function() -- Restore lsp table to clean state vim.lsp = {} end) it("should include the current working directory", function() local folders = lockfile.get_workspace_folders() assert("/mock/cwd" == folders[1]) end) it("should work with current Neovim API (get_clients)", function() -- Set up the current API mock create_mock_env("current") -- Test the function local folders = lockfile.get_workspace_folders() -- Verify results assert(3 == #folders) -- cwd + 2 workspace folders assert("/mock/folder1" == folders[2]) assert("/mock/folder2" == folders[3]) end) it("should work with legacy Neovim API (get_active_clients)", function() -- Set up the legacy API mock create_mock_env("legacy") -- Test the function local folders = lockfile.get_workspace_folders() -- Verify results assert(3 == #folders) -- cwd + 2 workspace folders assert("/mock/folder1" == folders[2]) assert("/mock/folder2" == folders[3]) end) it("should handle duplicate folder paths", function() -- Set up a mock with duplicates vim.lsp = { get_clients = function() return { { config = { workspace_folders = { { uri = "file:///mock/cwd" }, -- Same as cwd { uri = "file:///mock/folder" }, { uri = "file:///mock/folder" }, -- Duplicate }, }, }, } end, } -- Test the function local folders = lockfile.get_workspace_folders() -- Verify results assert(2 == #folders) -- cwd + 1 unique workspace folder end) end) describe("authentication token functionality", function() it("should generate auth tokens", function() local token1 = lockfile.generate_auth_token() local token2 = lockfile.generate_auth_token() -- Tokens should be strings assert("string" == type(token1)) assert("string" == type(token2)) -- Tokens should be different assert(token1 ~= token2) -- Tokens should match UUID format (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) assert(token1:match("^%x+%-%x+%-4%x+%-[89ab]%x+%-%x+$")) assert(token2:match("^%x+%-%x+%-4%x+%-[89ab]%x+%-%x+$")) end) it("should create lock files with auth tokens", function() local port = 12345 local success, lock_path, auth_token = lockfile.create(port) assert(success == true) assert("string" == type(lock_path)) assert("string" == type(auth_token)) -- Should be able to read the auth token back local read_success, read_token, read_error = lockfile.get_auth_token(port) assert(read_success == true) assert(auth_token == read_token) assert(read_error == nil) end) it("should create lock files with pre-generated auth tokens", function() local port = 12346 local preset_token = "test-auth-token-12345" local success, lock_path, returned_token = lockfile.create(port, preset_token) assert(success == true) assert("string" == type(lock_path)) assert(preset_token == returned_token) -- Should be able to read the preset token back local read_success, read_token, read_error = lockfile.get_auth_token(port) assert(read_success == true) assert(preset_token == read_token) assert(read_error == nil) end) it("should handle missing lock files when reading auth tokens", function() local nonexistent_port = 99999 local success, token, error = lockfile.get_auth_token(nonexistent_port) assert(success == false) assert(token == nil) assert("string" == type(error)) assert(error:find("Lock file does not exist")) end) end) end) ================================================ FILE: tests/minimal_init.lua ================================================ -- Minimal Neovim configuration for tests -- Set up package path local package_root = vim.fn.stdpath("data") .. "/site/pack/vendor/start/" local install_path = package_root .. "plenary.nvim" if vim.fn.empty(vim.fn.glob(install_path)) > 0 then vim.fn.system({ "git", "clone", "--depth", "1", "https://github.com/nvim-lua/plenary.nvim", install_path, }) vim.cmd([[packadd plenary.nvim]]) end -- Add package paths for development vim.opt.runtimepath:append(vim.fn.expand("$HOME/.local/share/nvim/site/pack/vendor/start/plenary.nvim")) -- Add current working directory to runtime path for development vim.opt.runtimepath:prepend(vim.fn.getcwd()) -- Set up test environment vim.g.mapleader = " " vim.g.maplocalleader = " " vim.opt.termguicolors = true vim.opt.timeoutlen = 300 vim.opt.updatetime = 250 -- Disable some built-in plugins local disabled_built_ins = { "gzip", "matchit", "matchparen", "netrwPlugin", "tarPlugin", "tohtml", "tutor", "zipPlugin", } for _, plugin in pairs(disabled_built_ins) do vim.g["loaded_" .. plugin] = 1 end -- Check for claudecode-specific tests by examining command line or environment local should_load = false -- Method 1: Check command line arguments for specific test files for _, arg in ipairs(vim.v.argv) do if arg:match("command_args_spec") or arg:match("mcp_tools_spec") then should_load = true break end end -- Method 2: Check if CLAUDECODE_INTEGRATION_TEST env var is set if not should_load and os.getenv("CLAUDECODE_INTEGRATION_TEST") == "true" then should_load = true end if not vim.g.loaded_claudecode and should_load then require("claudecode").setup({ auto_start = false, log_level = "trace", -- More verbose for tests }) end -- Global cleanup function for plenary test harness _G.claudecode_test_cleanup = function() -- Clear global deferred responses if _G.claude_deferred_responses then _G.claude_deferred_responses = {} end -- Stop claudecode if running local ok, claudecode = pcall(require, "claudecode") if ok and claudecode.state and claudecode.state.server then local selection_ok, selection = pcall(require, "claudecode.selection") if selection_ok and selection.disable then selection.disable() end if claudecode.stop then claudecode.stop() end end end -- Auto-cleanup when using plenary test harness if vim.env.PLENARY_TEST_HARNESS then vim.api.nvim_create_autocmd("VimLeavePre", { callback = function() _G.claudecode_test_cleanup() end, }) end ================================================ FILE: tests/selection_test.lua ================================================ if not _G.vim then _G.vim = { ---@type vim_global_api schedule_wrap = function(fn) return fn end, schedule = function(fn) fn() end, _buffers = {}, _windows = {}, _commands = {}, _autocmds = {}, _vars = {}, _options = {}, _current_mode = "n", api = { nvim_create_user_command = function(name, callback, opts) _G.vim._commands[name] = { callback = callback, opts = opts, } end, nvim_create_augroup = function(name, opts) _G.vim._autocmds[name] = { opts = opts, events = {}, } return name end, nvim_create_autocmd = function(events, opts) local group = opts.group or "default" if not _G.vim._autocmds[group] then _G.vim._autocmds[group] = { opts = {}, events = {}, } end local id = #_G.vim._autocmds[group].events + 1 _G.vim._autocmds[group].events[id] = { events = events, opts = opts, } return id end, nvim_clear_autocmds = function(opts) if opts.group then _G.vim._autocmds[opts.group] = nil end end, nvim_get_current_buf = function() return 1 end, nvim_buf_get_name = function(bufnr) return _G.vim._buffers[bufnr] and _G.vim._buffers[bufnr].name or "" end, nvim_get_current_win = function() return 1 end, nvim_win_get_cursor = function(winid) return _G.vim._windows[winid] and _G.vim._windows[winid].cursor or { 1, 0 } end, nvim_get_mode = function() return { mode = _G.vim._current_mode } end, nvim_buf_get_lines = function(bufnr, start, end_line, _strict) -- Prefix unused param with underscore if not _G.vim._buffers[bufnr] then return {} end local lines = _G.vim._buffers[bufnr].lines or {} local result = {} for i = start + 1, end_line do table.insert(result, lines[i] or "") end return result end, nvim_echo = function(chunks, history, opts) -- Just store the last echo message for testing _G.vim._last_echo = { chunks = chunks, history = history, opts = opts, } end, nvim_err_writeln = function(msg) _G.vim._last_error = msg end, }, cmd = function() end, ---@type fun(command: string):nil fs = { remove = function() end }, ---@type vim_fs_module fn = { ---@type vim_fn_table bufnr = function(name) for bufnr, buf in pairs(_G.vim._buffers) do if buf.name == name then return bufnr end end return -1 end, getpos = function(mark) if mark == "'<" then return { 0, 1, 1, 0 } elseif mark == "'>" then return { 0, 5, 10, 0 } end return { 0, 0, 0, 0 } end, -- Add other vim.fn mocks as needed by selection tests mode = function() return _G.vim._current_mode or "n" end, delete = function(_, _) return 0 end, filereadable = function(_) return 1 end, fnamemodify = function(fname, _) return fname end, expand = function(s, _) return s end, getcwd = function() return "/mock/cwd" end, mkdir = function(_, _, _) return 1 end, buflisted = function(_) return 1 end, bufname = function(_) return "mockbuffer" end, win_getid = function() return 1 end, win_gotoid = function(_) return true end, line = function(_) return 1 end, col = function(_) return 1 end, virtcol = function(_) return 1 end, setpos = function(_, _) return true end, tempname = function() return "/tmp/mocktemp" end, globpath = function(_, _) return "" end, stdpath = function(_) return "/mock/stdpath" end, json_encode = function(_) return "{}" end, json_decode = function(_) return {} end, termopen = function(_, _) return 0 end, }, defer_fn = function(fn, _timeout) -- Prefix unused param with underscore -- For testing, we'll execute immediately fn() end, loop = { timer_stop = function(_timer) -- Prefix unused param with underscore return true end, }, test = { ---@type vim_test_utils set_mode = function(mode) _G.vim._current_mode = mode end, set_cursor = function(win, row, col) if not _G.vim._windows[win] then _G.vim._windows[win] = {} end _G.vim._windows[win].cursor = { row, col } end, add_buffer = function(bufnr, name, content) local lines = {} if type(content) == "string" then for line in content:gmatch("([^\n]*)\n?") do table.insert(lines, line) end elseif type(content) == "table" then lines = content end _G.vim._buffers[bufnr] = { name = name, lines = lines, options = {}, listed = true, } end, }, notify = function(_, _, _) end, log = { levels = { NONE = 0, ERROR = 1, WARN = 2, INFO = 3, DEBUG = 4, TRACE = 5, }, }, o = { ---@type vim_options_table columns = 80, lines = 24, }, -- Mock for vim.o bo = setmetatable({}, { -- Mock for vim.bo and vim.bo[bufnr] __index = function(t, k) if type(k) == "number" then if not t[k] then t[k] = {} -- Return a new table for vim.bo[bufnr] end return t[k] end return nil end, }), diagnostic = { -- Mock for vim.diagnostic get = function() return {} end, -- Add other vim.diagnostic functions if needed by tests }, empty_dict = function() return {} end, -- Mock for vim.empty_dict() g = {}, -- Mock for vim.g deepcopy = function(orig) local orig_type = type(orig) local copy if orig_type == "table" then copy = {} for orig_key, orig_value in next, orig, nil do copy[_G.vim.deepcopy(orig_key)] = _G.vim.deepcopy(orig_value) end setmetatable(copy, _G.vim.deepcopy(getmetatable(orig))) else copy = orig end return copy end, tbl_deep_extend = function(behavior, ...) local tables = { ... } if #tables == 0 then return {} end local result = _G.vim.deepcopy(tables[1]) for i = 2, #tables do local source = tables[i] if type(source) == "table" then for k, v in pairs(source) do if behavior == "force" then if type(v) == "table" and type(result[k]) == "table" then result[k] = _G.vim.tbl_deep_extend(behavior, result[k], v) else result[k] = _G.vim.deepcopy(v) end elseif behavior == "keep" then if result[k] == nil then result[k] = _G.vim.deepcopy(v) elseif type(v) == "table" and type(result[k]) == "table" then result[k] = _G.vim.tbl_deep_extend(behavior, result[k], v) end -- Add other behaviors like "error" if needed by tests end end end end return result end, } _G.vim.test.add_buffer(1, "/path/to/test.lua", "local test = {}\nreturn test") _G.vim.test.set_cursor(1, 1, 0) end -- luacheck: globals mock_server describe("Selection module", function() local selection mock_server = { broadcast = function(event, data) -- Store last broadcast for testing mock_server.last_broadcast = { event = event, data = data, } end, last_broadcast = nil, } setup(function() package.loaded["claudecode.selection"] = nil selection = require("claudecode.selection") end) teardown(function() if selection.state.tracking_enabled then selection.disable() end mock_server.last_broadcast = nil end) it("should have the correct initial state", function() assert(type(selection.state) == "table") assert(selection.state.latest_selection == nil) assert(selection.state.tracking_enabled == false) assert(selection.state.debounce_timer == nil) assert(type(selection.state.debounce_ms) == "number") end) it("should enable and disable tracking", function() selection.enable(mock_server) assert(selection.state.tracking_enabled == true) assert(mock_server == selection.server) selection.disable() assert(selection.state.tracking_enabled == false) assert(selection.server == nil) assert(selection.state.latest_selection == nil) end) it("should get cursor position in normal mode", function() local old_win_get_cursor = _G.vim.api.nvim_win_get_cursor _G.vim.api.nvim_win_get_cursor = function() return { 2, 3 } -- row 2, col 3 (1-based) end _G.vim.test.set_mode("n") local cursor_pos = selection.get_cursor_position() _G.vim.api.nvim_win_get_cursor = old_win_get_cursor assert(type(cursor_pos) == "table") assert("" == cursor_pos.text) assert(type(cursor_pos.filePath) == "string") assert(type(cursor_pos.fileUrl) == "string") assert(type(cursor_pos.selection) == "table") assert(type(cursor_pos.selection.start) == "table") assert(type(cursor_pos.selection["end"]) == "table") -- Check positions - 0-based in selection, source is 1-based from nvim_win_get_cursor assert(1 == cursor_pos.selection.start.line) -- Should be 2-1=1 assert(3 == cursor_pos.selection.start.character) assert(1 == cursor_pos.selection["end"].line) assert(3 == cursor_pos.selection["end"].character) assert(cursor_pos.selection.isEmpty == true) end) it("should detect selection changes", function() local old_selection = { text = "test", filePath = "/path/file1.lua", fileUrl = "file:///path/file1.lua", selection = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 4 }, isEmpty = false, }, } local new_selection_same = { text = "test", filePath = "/path/file1.lua", fileUrl = "file:///path/file1.lua", selection = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 4 }, isEmpty = false, }, } local new_selection_diff_file = { text = "test", filePath = "/path/file2.lua", fileUrl = "file:///path/file2.lua", selection = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 4 }, isEmpty = false, }, } local new_selection_diff_text = { text = "test2", filePath = "/path/file1.lua", fileUrl = "file:///path/file1.lua", selection = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 5 }, isEmpty = false, }, } local new_selection_diff_pos = { text = "test", filePath = "/path/file1.lua", fileUrl = "file:///path/file1.lua", selection = { start = { line = 2, character = 0 }, ["end"] = { line = 2, character = 4 }, isEmpty = false, }, } selection.state.latest_selection = old_selection assert(selection.has_selection_changed(new_selection_same) == false) assert(selection.has_selection_changed(new_selection_diff_file) == true) assert(selection.has_selection_changed(new_selection_diff_text) == true) assert(selection.has_selection_changed(new_selection_diff_pos) == true) end) end) -- Tests for range selection functionality (fix for issue #25) describe("Range Selection Tests", function() local selection before_each(function() -- Reset vim state _G.vim._buffers = { [1] = { name = "/test/file.lua", lines = { "line 1", "line 2", "line 3", "line 4", "line 5", "line 6", "line 7", "line 8", "line 9", "line 10", }, }, } _G.vim._windows = { [1] = { cursor = { 1, 0 }, }, } _G.vim._current_mode = "n" -- Add nvim_buf_line_count function _G.vim.api.nvim_buf_line_count = function(bufnr) return _G.vim._buffers[bufnr] and #_G.vim._buffers[bufnr].lines or 0 end -- Reload the selection module package.loaded["claudecode.selection"] = nil selection = require("claudecode.selection") end) describe("get_range_selection", function() it("should return valid selection for valid range", function() local result = selection.get_range_selection(2, 4) assert(result ~= nil) assert(result.text == "line 2\nline 3\nline 4") assert(result.filePath == "/test/file.lua") assert(result.fileUrl == "file:///test/file.lua") assert(result.selection.start.line == 1) -- 0-indexed assert(result.selection.start.character == 0) assert(result.selection["end"].line == 3) -- 0-indexed assert(result.selection["end"].character == 6) -- length of "line 4" assert(result.selection.isEmpty == false) end) it("should return valid selection for single line range", function() local result = selection.get_range_selection(3, 3) assert(result ~= nil) assert(result.text == "line 3") assert(result.selection.start.line == 2) -- 0-indexed assert(result.selection["end"].line == 2) -- 0-indexed assert(result.selection.isEmpty == false) end) it("should handle range that exceeds buffer bounds", function() local result = selection.get_range_selection(8, 15) -- buffer only has 10 lines assert(result ~= nil) assert(result.text == "line 8\nline 9\nline 10") assert(result.selection.start.line == 7) -- 0-indexed assert(result.selection["end"].line == 9) -- 0-indexed, clamped to buffer size end) it("should return nil for invalid range (line1 > line2)", function() local result = selection.get_range_selection(5, 3) assert(result == nil) end) it("should return nil for invalid range (line1 < 1)", function() local result = selection.get_range_selection(0, 3) assert(result == nil) end) it("should return nil for invalid range (line2 < 1)", function() local result = selection.get_range_selection(2, 0) assert(result == nil) end) it("should return nil for nil parameters", function() local result1 = selection.get_range_selection(nil, 3) local result2 = selection.get_range_selection(2, nil) local result3 = selection.get_range_selection(nil, nil) assert(result1 == nil) assert(result2 == nil) assert(result3 == nil) end) it("should handle empty buffer", function() _G.vim._buffers[1].lines = {} local result = selection.get_range_selection(1, 1) assert(result == nil) end) end) describe("send_at_mention_for_visual_selection with range", function() local mock_server local mock_claudecode_main local original_require before_each(function() mock_server = { broadcast = function(event, params) mock_server.last_broadcast = { event = event, params = params, } return true end, } mock_claudecode_main = { state = { server = mock_server, }, send_at_mention = function(file_path, start_line, end_line, context) -- Convert to the format expected by tests (1-indexed to 0-indexed conversion done here) local params = { filePath = file_path, lineStart = start_line, lineEnd = end_line, } return mock_server.broadcast("at_mentioned", params), nil end, } -- Mock the require function to return our mock claudecode module original_require = _G.require _G.require = function(module_name) if module_name == "claudecode" then return mock_claudecode_main else return original_require(module_name) end end selection.state.tracking_enabled = true selection.server = mock_server end) after_each(function() _G.require = original_require end) it("should send range selection successfully", function() local result = selection.send_at_mention_for_visual_selection(2, 4) assert(result == true) assert(mock_server.last_broadcast ~= nil) assert(mock_server.last_broadcast.event == "at_mentioned") assert(mock_server.last_broadcast.params.filePath == "/test/file.lua") assert(mock_server.last_broadcast.params.lineStart == 1) -- 0-indexed assert(mock_server.last_broadcast.params.lineEnd == 3) -- 0-indexed end) it("should fail for invalid range", function() local result = selection.send_at_mention_for_visual_selection(5, 3) assert(result == false) end) it("should fall back to existing logic when no range provided", function() -- Set up a tracked selection selection.state.latest_selection = { text = "tracked text", filePath = "/test/file.lua", fileUrl = "file:///test/file.lua", selection = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 12 }, isEmpty = false, }, } local result = selection.send_at_mention_for_visual_selection() assert(result == true) assert(mock_server.last_broadcast.params.lineStart == 0) assert(mock_server.last_broadcast.params.lineEnd == 0) end) it("should fail when server is not available", function() mock_claudecode_main.state.server = nil local result = selection.send_at_mention_for_visual_selection(2, 4) assert(result == false) end) it("should fail when tracking is disabled", function() selection.state.tracking_enabled = false local result = selection.send_at_mention_for_visual_selection(2, 4) assert(result == false) end) end) end) ================================================ FILE: tests/server_test.lua ================================================ -- Create minimal vim mock if it doesn't exist if not _G.vim then _G.vim = { ---@type vim_global_api schedule_wrap = function(fn) return fn end, deepcopy = function(t) local copy = {} for k, v in pairs(t) do if type(v) == "table" then copy[k] = _G.vim.deepcopy(v) else copy[k] = v end end return copy end, tbl_deep_extend = function(behavior, ...) local result = {} local tables = { ... } for _, tbl in ipairs(tables) do for k, v in pairs(tbl) do if type(v) == "table" and type(result[k]) == "table" then result[k] = _G.vim.tbl_deep_extend(behavior, result[k], v) else result[k] = v end end end return result end, json = { encode = function(data) return "{}" end, decode = function(json_str) return {} end, }, loop = { new_tcp = function() return { bind = function(self, host, port) return true end, listen = function(self, backlog, callback) return true end, accept = function(self, client) return true end, read_start = function(self, callback) return true end, write = function(self, data, callback) if callback then callback() end return true end, close = function(self) return true end, is_closing = function(self) return false end, } end, new_timer = function() return { start = function(self, timeout, repeat_interval, callback) return true end, stop = function(self) return true end, close = function(self) return true end, } end, now = function() return os.time() * 1000 end, }, schedule = function(callback) callback() end, -- Added notify and log mocks notify = function(_, _, _) end, log = { levels = { NONE = 0, ERROR = 1, WARN = 2, INFO = 3, DEBUG = 4, TRACE = 5, }, }, o = { ---@type vim_options_table columns = 80, lines = 24, }, bo = setmetatable({}, { __index = function(t, k) if type(k) == "number" then if not t[k] then t[k] = {} -- Return a new table for vim.bo[bufnr] end return t[k] end return nil end, }), diagnostic = { get = function() return {} end, -- Add other vim.diagnostic functions if needed by tests }, empty_dict = function() return {} end, cmd = function() end, ---@type fun(command: string):nil api = {}, ---@type table fn = { ---@type vim_fn_table mode = function() return "n" end, delete = function(_, _) return 0 end, filereadable = function(_) return 1 end, fnamemodify = function(fname, _) return fname end, expand = function(s, _) return s end, getcwd = function() return "/mock/cwd" end, mkdir = function(_, _, _) return 1 end, buflisted = function(_) return 1 end, bufname = function(_) return "mockbuffer" end, bufnr = function(_) return 1 end, win_getid = function() return 1 end, win_gotoid = function(_) return true end, line = function(_) return 1 end, col = function(_) return 1 end, virtcol = function(_) return 1 end, getpos = function(_) return { 0, 1, 1, 0 } end, setpos = function(_, _) return true end, tempname = function() return "/tmp/mocktemp" end, globpath = function(_, _) return "" end, termopen = function(_, _) return 0 end, stdpath = function(_) return "/mock/stdpath" end, json_encode = function(_) return "{}" end, json_decode = function(_) return {} end, }, fs = { remove = function() end }, ---@type vim_fs_module } end describe("Server module", function() local server setup(function() -- Reset the module package.loaded["claudecode.server.init"] = nil -- Also update package.loaded key server = require("claudecode.server.init") end) teardown(function() if server.state.server then server.stop() end end) it("should have an empty initial state", function() assert(type(server.state) == "table") assert(server.state.server == nil) assert(server.state.port == nil) assert(type(server.state.clients) == "table") assert(type(server.state.handlers) == "table") end) it("should have get_status function", function() local status = server.get_status() assert(type(status) == "table") assert(status.running == false) assert(status.port == nil) assert(status.client_count == 0) end) it("should start and stop the server", function() local config = { port_range = { min = 10000, max = 65535, }, } local start_success, result = server.start(config) assert(start_success == true) assert(type(result) == "number") assert(server.state.server ~= nil) assert(server.state.port ~= nil) local stop_success = server.stop() assert(stop_success == true) assert(server.state.server == nil) assert(server.state.port == nil) assert(type(server.state.clients) == "table") assert(0 == #server.state.clients) end) it("should not stop the server if not running", function() -- Ensure server is not running if server.state.server then server.stop() end local success, error = server.stop() assert(success == false) assert("Server not running" == error) end) end) ================================================ FILE: tests/simple_test.lua ================================================ -- Simple test without using the mock vim API describe("Simple Tests", function() describe("Math operations", function() it("should add numbers correctly", function() assert.are.equal(4, 2 + 2) end) it("should multiply numbers correctly", function() assert.are.equal(6, 2 * 3) end) end) describe("String operations", function() it("should concatenate strings", function() assert.are.equal("Hello World", "Hello " .. "World") end) end) end) ================================================ FILE: tests/helpers/setup.lua ================================================ -- Test environment setup -- This function sets up the test environment return function() -- Create mock vim API if we're running tests outside of Neovim if not vim then -- luacheck: ignore _G.vim = require("tests.mocks.vim") end -- Setup test globals _G.assert = require("luassert") _G.stub = require("luassert.stub") _G.spy = require("luassert.spy") _G.mock = require("luassert.mock") -- Helper function to verify a test passes _G.it = function(desc, fn) local ok, err = pcall(fn) if not ok then print("FAIL: " .. desc) print(err) error("Test failed: " .. desc) else print("PASS: " .. desc) end end -- Helper function to describe a test group _G.describe = function(desc, fn) print("\n==== " .. desc .. " ====") fn() end -- Load the plugin under test package.loaded["claudecode"] = nil -- Return true to indicate setup was successful return true end ================================================ FILE: tests/integration/basic_spec.lua ================================================ local assert = require("luassert") describe("Claudecode Integration", function() it("should pass placeholder test", function() -- Simple placeholder test that will always pass assert.is_true(true) end) end) ================================================ FILE: tests/integration/command_args_spec.lua ================================================ require("tests.busted_setup") require("tests.mocks.vim") describe("ClaudeCode command arguments integration", function() local claudecode local mock_server local mock_lockfile local mock_selection local executed_commands local original_require before_each(function() executed_commands = {} local terminal_jobs = {} -- Mock vim.fn.termopen to capture actual commands and properly simulate terminal lifecycle vim.fn.termopen = function(cmd, opts) local job_id = 123 + #terminal_jobs table.insert(executed_commands, { cmd = cmd, opts = opts, }) -- Store the job for cleanup table.insert(terminal_jobs, { id = job_id, on_exit = opts and opts.on_exit, }) -- In headless test mode, immediately schedule the terminal exit -- This simulates the terminal closing right away to prevent hanging if opts and opts.on_exit then vim.schedule(function() opts.on_exit(job_id, 0, "exit") end) end return job_id end vim.fn.mode = function() return "n" end vim.o = { columns = 120, lines = 30, } vim.api.nvim_feedkeys = function() end vim.api.nvim_replace_termcodes = function(str) return str end local create_user_command_calls = {} vim.api.nvim_create_user_command = setmetatable({ calls = create_user_command_calls, }, { __call = function(self, ...) table.insert(create_user_command_calls, { vals = { ... } }) end, }) vim.api.nvim_create_autocmd = function() end vim.api.nvim_create_augroup = function() return 1 end vim.api.nvim_get_current_win = function() return 1 end vim.api.nvim_set_current_win = function() end vim.api.nvim_win_set_height = function() end vim.api.nvim_win_call = function(winid, func) func() end vim.api.nvim_get_current_buf = function() return 1 end vim.api.nvim_win_close = function() end vim.api.nvim_buf_is_valid = function() return false end vim.api.nvim_win_is_valid = function() return true end vim.api.nvim_list_wins = function() return { 1 } end vim.api.nvim_win_get_buf = function() return 1 end vim.api.nvim_list_bufs = function() return { 1 } end vim.api.nvim_buf_get_option = function() return "terminal" end vim.api.nvim_buf_get_name = function() return "terminal://claude" end vim.cmd = function() end vim.bo = setmetatable({}, { __index = function() return {} end, __newindex = function() end, }) vim.schedule = function(func) func() end -- Mock vim.notify to prevent terminal notifications in headless mode vim.notify = function() end mock_server = { start = function() return true, 12345 end, stop = function() return true end, state = { port = 12345 }, } mock_lockfile = { create = function() return true, "/mock/path" end, remove = function() return true end, } mock_selection = { enable = function() end, disable = function() end, } original_require = _G.require _G.require = function(mod) if mod == "claudecode.server.init" then return mock_server elseif mod == "claudecode.lockfile" then return mock_lockfile elseif mod == "claudecode.selection" then return mock_selection elseif mod == "claudecode.config" then return { apply = function(opts) return vim.tbl_deep_extend("force", { port_range = { min = 10000, max = 65535 }, auto_start = false, terminal_cmd = nil, log_level = "info", track_selection = true, visual_demotion_delay_ms = 50, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, vertical_split = true, open_in_current_tab = false, }, }, opts or {}) end, } elseif mod == "claudecode.diff" then return { setup = function() end, } elseif mod == "claudecode.logger" then return { setup = function() end, debug = function() end, error = function() end, warn = function() end, } else return original_require(mod) end end -- Clear package cache to ensure fresh requires package.loaded["claudecode"] = nil package.loaded["claudecode.terminal"] = nil package.loaded["claudecode.terminal.snacks"] = nil package.loaded["claudecode.terminal.native"] = nil claudecode = require("claudecode") end) after_each(function() -- CRITICAL: Add explicit cleanup to prevent hanging if claudecode and claudecode.state and claudecode.state.server then -- Clean up global deferred responses that prevent garbage collection if _G.claude_deferred_responses then _G.claude_deferred_responses = {} end -- Stop the server and selection tracking explicitly local selection_ok, selection = pcall(require, "claudecode.selection") if selection_ok and selection.disable then selection.disable() end if claudecode.stop then claudecode.stop() end end _G.require = original_require package.loaded["claudecode"] = nil package.loaded["claudecode.terminal"] = nil package.loaded["claudecode.terminal.snacks"] = nil package.loaded["claudecode.terminal.native"] = nil end) describe("with native terminal provider", function() it("should execute terminal command with appended arguments", function() claudecode.setup({ auto_start = false, terminal_cmd = "test_claude_cmd", terminal = { provider = "native" }, }) -- Find and execute the ClaudeCode command local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end assert.is_function(command_handler, "ClaudeCode command handler should exist") command_handler({ args = "--resume --verbose" }) -- Verify the command was called with arguments assert.is_true(#executed_commands > 0, "No terminal commands were executed") local last_cmd = executed_commands[#executed_commands] -- For native terminal, cmd should be a table if type(last_cmd.cmd) == "table" then local cmd_string = table.concat(last_cmd.cmd, " ") assert.is_true(cmd_string:find("test_claude_cmd") ~= nil, "Base command not found in: " .. cmd_string) assert.is_true(cmd_string:find("--resume") ~= nil, "Arguments not found in: " .. cmd_string) assert.is_true(cmd_string:find("--verbose") ~= nil, "Arguments not found in: " .. cmd_string) else assert.is_true(last_cmd.cmd:find("test_claude_cmd") ~= nil, "Base command not found") assert.is_true(last_cmd.cmd:find("--resume") ~= nil, "Arguments not found") assert.is_true(last_cmd.cmd:find("--verbose") ~= nil, "Arguments not found") end end) it("should work with default claude command and arguments", function() claudecode.setup({ auto_start = false, terminal = { provider = "native" }, }) local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeOpen" then command_handler = call.vals[2] break end end command_handler({ args = "--help" }) assert.is_true(#executed_commands > 0, "No terminal commands were executed") local last_cmd = executed_commands[#executed_commands] local cmd_string = type(last_cmd.cmd) == "table" and table.concat(last_cmd.cmd, " ") or last_cmd.cmd assert.is_true(cmd_string:find("claude") ~= nil, "Default claude command not found") assert.is_true(cmd_string:find("--help") ~= nil, "Arguments not found") end) it("should handle empty arguments gracefully", function() claudecode.setup({ auto_start = false, terminal_cmd = "claude", terminal = { provider = "native" }, }) local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end command_handler({ args = "" }) assert.is_true(#executed_commands > 0, "No terminal commands were executed") local last_cmd = executed_commands[#executed_commands] local cmd_string = type(last_cmd.cmd) == "table" and table.concat(last_cmd.cmd, " ") or last_cmd.cmd assert.is_true( cmd_string == "claude" or cmd_string:find("^claude$") ~= nil, "Command should be just 'claude' without extra arguments" ) end) end) describe("edge cases", function() it("should handle special characters in arguments", function() claudecode.setup({ auto_start = false, terminal_cmd = "claude", terminal = { provider = "native" }, }) local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end command_handler({ args = "--message='hello world' --path=/tmp/test" }) assert.is_true(#executed_commands > 0, "No terminal commands were executed") local last_cmd = executed_commands[#executed_commands] local cmd_string = type(last_cmd.cmd) == "table" and table.concat(last_cmd.cmd, " ") or last_cmd.cmd assert.is_true(cmd_string:find("--message='hello world'") ~= nil, "Special characters not preserved") assert.is_true(cmd_string:find("--path=/tmp/test") ~= nil, "Path arguments not preserved") end) it("should handle very long argument strings", function() claudecode.setup({ auto_start = false, terminal_cmd = "claude", terminal = { provider = "native" }, }) local long_args = string.rep("--flag ", 50) .. "--final" local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end command_handler({ args = long_args }) assert.is_true(#executed_commands > 0, "No terminal commands were executed") local last_cmd = executed_commands[#executed_commands] local cmd_string = type(last_cmd.cmd) == "table" and table.concat(last_cmd.cmd, " ") or last_cmd.cmd assert.is_true(cmd_string:find("--final") ~= nil, "Long arguments not preserved") end) end) describe("backward compatibility", function() it("should not break existing calls without arguments", function() claudecode.setup({ auto_start = false, terminal_cmd = "claude", terminal = { provider = "native" }, }) local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end command_handler({}) assert.is_true(#executed_commands > 0, "No terminal commands were executed") local last_cmd = executed_commands[#executed_commands] local cmd_string = type(last_cmd.cmd) == "table" and table.concat(last_cmd.cmd, " ") or last_cmd.cmd assert.is_true(cmd_string == "claude" or cmd_string:find("^claude$") ~= nil, "Should work exactly as before") end) it("should maintain existing ClaudeCodeClose command functionality", function() claudecode.setup({ auto_start = false }) local close_command_found = false for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeClose" then close_command_found = true local config = call.vals[3] assert.is_nil(config.nargs, "ClaudeCodeClose should not accept arguments") break end end assert.is_true(close_command_found, "ClaudeCodeClose command should still be registered") end) end) end) ================================================ FILE: tests/integration/mcp_tools_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("MCP Tools Integration", function() -- Clear module cache at the start of the describe block package.loaded["claudecode.server.init"] = nil package.loaded["claudecode.tools.init"] = nil package.loaded["claudecode.diff"] = nil -- Mock the selection module before other modules might require it package.loaded["claudecode.selection"] = { get_latest_selection = function() return { file_path = "/test/selection.lua", content = "test selection content", start_line = 1, end_line = 1, } end, } -- Ensure _G.vim is initialized by busted_setup (can be asserted here or assumed) assert(_G.vim, "Global vim mock not initialized by busted_setup.lua") assert(_G.vim.fn, "Global vim.fn mock not initialized") assert(_G.vim.api, "Global vim.api mock not initialized") -- Load modules (these will now use the _G.vim provided by busted_setup and fresh caches) local server = require("claudecode.server.init") local tools = require("claudecode.tools.init") local original_vim_functions = {} -- To store original functions if we override them ---@class MCPToolInputSchemaProperty ---@field type string ---@field description string ---@field default any? ---@class MCPToolInputSchema ---@field type string ---@field properties table ---@field required string[]? ---@class MCPToolDefinition ---@field name string ---@field description string ---@field inputSchema MCPToolInputSchema ---@field outputSchema table? -- Simplified for now ---@class MCPResultContentItem ---@field type string ---@field text string? ---@field language string? ---@field source string? -- For images, etc. ---@class MCPToolResult ---@field content MCPResultContentItem[]? ---@field isError boolean? ---@field error table? -- Contains code, message, data (MCPErrorData structure) ---@class MCPErrorData ---@field code number ---@field message string ---@field data any? -- The setup() function's work is now done above. -- local function setup() ... end -- Removed local function teardown() -- Restore any original vim functions that were overridden in setup() for path, func in pairs(original_vim_functions) do local parts = {} for part in string.gmatch(path, "[^%.]+") do table.insert(parts, part) end local obj = _G.vim for i = 1, #parts - 1 do obj = obj[parts[i]] end obj[parts[#parts]] = func end original_vim_functions = {} -- _G.vim itself is managed by busted_setup.lua; no need to nil it here -- unless busted_setup doesn't restore it between spec files. end -- setup() -- Call removed as setup work is done at the top of describe describe("Tools List Handler", function() it("should return complete tool definitions", function() server.register_handlers() tools.setup(server) local handler = server.state.handlers["tools/list"] expect(handler).to_be_function() local result = handler(nil, {}) expect(result.tools).to_be_table() expect(#result.tools).to_be_at_least(4) local openDiff_tool = nil for _, tool in ipairs(result.tools) do if tool.name == "openDiff" then openDiff_tool = tool break end end expect(openDiff_tool).not_to_be_nil() ---@cast openDiff_tool MCPToolDefinition expect(type(openDiff_tool.description)).to_be("string") expect(openDiff_tool.description:find("diff view")).to_be_truthy() expect(openDiff_tool.inputSchema).to_be_table() expect(openDiff_tool.inputSchema.type).to_be("object") expect(openDiff_tool.inputSchema.required).to_be_table() expect(#openDiff_tool.inputSchema.required).to_be(4) local required = openDiff_tool.inputSchema.required local has_old_file_path = false local has_new_file_path = false local has_new_file_contents = false local has_tab_name = false if required then for _, param in ipairs(required) do if param == "old_file_path" then has_old_file_path = true end if param == "new_file_path" then has_new_file_path = true end if param == "new_file_contents" then has_new_file_contents = true end if param == "tab_name" then has_tab_name = true end end end expect(has_old_file_path).to_be_true() expect(has_new_file_path).to_be_true() expect(has_new_file_contents).to_be_true() expect(has_tab_name).to_be_true() local props = openDiff_tool.inputSchema.properties expect(props.old_file_path.type).to_be("string") expect(props.new_file_path.type).to_be("string") expect(props.new_file_contents.type).to_be("string") expect(props.tab_name.type).to_be("string") end) it("should include other essential tools", function() server.register_handlers() tools.setup(server) local handler = server.state.handlers["tools/list"] local result = handler(nil, {}) local tool_names = {} for _, tool in ipairs(result.tools) do table.insert(tool_names, tool.name) end local has_openFile = false local has_getCurrentSelection = false local has_getOpenEditors = false local has_openDiff = false for _, name in ipairs(tool_names) do if name == "openFile" then has_openFile = true end if name == "getCurrentSelection" then has_getCurrentSelection = true end if name == "getOpenEditors" then has_getOpenEditors = true end if name == "openDiff" then has_openDiff = true end end expect(has_openFile).to_be_true() expect(has_getCurrentSelection).to_be_true() expect(has_getOpenEditors).to_be_true() expect(has_openDiff).to_be_true() end) end) describe("Tools Call Handler", function() before_each(function() -- Mock the tools module to isolate handler logic. tools = { handle_invoke = function(client, params) if params.name == "openDiff" then local required_params = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" } for _, param in ipairs(required_params) do if not params.arguments[param] then return { result = { content = { { type = "text", text = "Error: Missing required parameter: " .. param, }, }, isError = true, }, } end end return { result = { content = { { type = "text", text = "Diff opened using native provider: " .. params.arguments.tab_name, }, }, }, } elseif params.name == "getOpenEditors" then return { result = { content = { { type = "text", text = "[]", }, }, }, } elseif params.name == "openFile" then return { result = { content = { { type = "text", text = "File opened: " .. params.arguments.filePath, }, }, }, } elseif params.name == "getCurrentSelection" then return { result = { content = { { type = "text", text = '{"file_path": "/test/selection.lua", "content": "test selection content", "start_line": 1, "end_line": 1}', }, }, }, } else return { error = { code = -32601, message = "Tool not found: " .. params.name, }, } end end, } server.register_handlers() -- Patch server's "tools/call" handler to use the mocked tools.handle_invoke. server.state.handlers["tools/call"] = function(client, params) local result_or_error_table = tools.handle_invoke(client, params) if result_or_error_table.error then return nil, result_or_error_table.error elseif result_or_error_table.result then return result_or_error_table.result, nil else return nil, { code = -32603, message = "Internal error", data = "Tool handler returned unexpected format", } end end end) it("should handle openDiff tool call successfully", function() -- Mock io.open for temporary file creation local mock_file = { write = function() end, close = function() end, } local old_io_open = io.open rawset(io, "open", function() return mock_file end) local handler = server.state.handlers["tools/call"] expect(handler).to_be_function() local params = { name = "openDiff", arguments = { old_file_path = "/test/old.lua", new_file_path = "/test/new.lua", new_file_contents = "function test()\n return 'new'\nend", tab_name = "Integration Test Diff", }, } local result, error_data = handler(nil, params) expect(result).to_be_table() ---@cast result MCPToolResult expect(error_data).to_be_nil() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") expect(type(result.content[1].text)).to_be("string") expect(result.content[1].text:find("Diff opened using")).to_be_truthy() expect(result.content[1].text:find("Integration Test Diff")).to_be_truthy() rawset(io, "open", old_io_open) end) it("should handle missing parameters in openDiff", function() local handler = server.state.handlers["tools/call"] local params = { name = "openDiff", arguments = { old_file_path = "/test/old.lua", }, } local result, error_data = handler(nil, params) expect(result).to_be_table() expect(error_data).to_be_nil() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") expect(type(result.content[1].text)).to_be("string") expect(result.content[1].text:find("Missing required parameter")).to_be_truthy() expect(result.isError).to_be_true() end) it("should handle getOpenEditors tool call", function() local handler = server.state.handlers["tools/call"] local params = { name = "getOpenEditors", arguments = {}, } local result, error_data = handler(nil, params) expect(result).to_be_table() expect(error_data).to_be_nil() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") expect(result.content[1].text).to_be_string() -- The JSON is encoded as text. end) it("should handle openFile tool call", function() local handler = server.state.handlers["tools/call"] local params = { name = "openFile", arguments = { filePath = "/test/existing.lua", }, } local result, error_data = handler(nil, params) expect(result).to_be_table() expect(error_data).to_be_nil() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") expect(type(result.content[1].text)).to_be("string") expect(result.content[1].text:find("File opened")).to_be_truthy() end) it("should handle unknown tool gracefully", function() local handler = server.state.handlers["tools/call"] local params = { name = "unknownTool", arguments = {}, } local result, error_data = handler(nil, params) expect(result).to_be_nil() expect(error_data).to_be_table() ---@cast error_data MCPErrorData expect(error_data.code).to_be(-32601) expect(type(error_data.message)).to_be("string") expect(error_data.message:find("Tool not found")).to_be_truthy() end) it("should handle tool execution errors", function() -- Temporarily replace the tools mock to simulate an error local error_tools = { handle_invoke = function(client, params) if params.name == "openDiff" then return { result = { content = { { type = "text", text = "Error opening diff: Simulated diff error", }, }, isError = true, }, } else return { error = { code = -32601, message = "Tool not found: " .. params.name, }, } end end, } -- Replace the handler with error behavior server.state.handlers["tools/call"] = function(client, params) local result_or_error_table = error_tools.handle_invoke(client, params) if result_or_error_table.error then return nil, result_or_error_table.error elseif result_or_error_table.result then return result_or_error_table.result, nil else return nil, { code = -32603, message = "Internal error", data = "Tool handler returned unexpected format", } end end local handler = server.state.handlers["tools/call"] local params = { name = "openDiff", arguments = { old_file_path = "/test/old.lua", new_file_path = "/test/new.lua", new_file_contents = "content", tab_name = "Error Test", }, } local result, error_data = handler(nil, params) expect(result).to_be_table() expect(error_data).to_be_nil() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") expect(type(result.content[1].text)).to_be("string") expect(result.content[1].text:find("Error opening diff")).to_be_truthy() expect(result.isError).to_be_true() end) end) describe("End-to-End MCP Protocol Flow", function() it("should complete full openDiff workflow", function() -- Mock io.open for temporary file creation local temp_files_created = {} -- luacheck: ignore temp_files_created local mock_file = { write = function(self, content) self.content = content end, close = function() end, } local old_io_open = io.open rawset(io, "open", function(filename, mode) temp_files_created[filename] = { content = "", mode = mode } return mock_file end) -- Track vim commands local vim_commands = {} -- Save original _G.vim.cmd if it hasn't been backed up yet in original_vim_functions if _G.vim and rawget(original_vim_functions, "cmd") == nil then original_vim_functions["cmd"] = _G.vim.cmd -- Save current value (can be nil or function) end _G.vim.cmd = function(cmd) table.insert(vim_commands, cmd) end -- Use the same mock tools for end-to-end test tools = { get_tool_list = function() return { { name = "openDiff", description = "Open a diff view comparing old file content with new file content", inputSchema = { type = "object", required = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" }, }, }, } end, handle_invoke = function(client, params) if params.name == "openDiff" then local required_params = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" } for _, param in ipairs(required_params) do if not params.arguments[param] then return { result = { content = { { type = "text", text = "Error: Missing required parameter: " .. param, }, }, isError = true, }, } end end return { result = { content = { { type = "text", text = "Diff opened using native provider: " .. params.arguments.tab_name, }, }, }, } else return { error = { code = -32601, message = "Tool not found: " .. params.name, }, } end end, } server.register_handlers() -- Replace the tools reference in server handlers server.state.handlers["tools/list"] = function(client, params) return { tools = tools.get_tool_list(), } end server.state.handlers["tools/call"] = function(client, params) local result_or_error_table = tools.handle_invoke(client, params) if result_or_error_table.error then return nil, result_or_error_table.error elseif result_or_error_table.result then return result_or_error_table.result, nil else return nil, { code = -32603, message = "Internal error", data = "Tool handler returned unexpected format", } end end local list_handler = server.state.handlers["tools/list"] local tools_list = list_handler(nil, {}) expect(tools_list.tools).to_be_table() local call_handler = server.state.handlers["tools/call"] local call_params = { name = "openDiff", arguments = { old_file_path = "/project/src/utils.lua", new_file_path = "/project/src/utils.lua", new_file_contents = "-- Updated utils\nfunction utils.helper()\n return 'updated'\nend", tab_name = "Utils Update", }, } local call_result, call_error = call_handler(nil, call_params) expect(call_result).to_be_table() expect(call_error).to_be_nil() expect(call_result.content).to_be_table() expect(call_result.content[1].type).to_be("text") expect(type(call_result.content[1].text)).to_be("string") expect(call_result.content[1].text:find("Diff opened")).to_be_truthy() expect(call_result.content[1].text:find("Utils Update")).to_be_truthy() -- Note: With mock tools, we don't actually execute vim commands or create temp files -- but we can verify the response indicates success -- The actual diff functionality is tested in unit tests rawset(io, "open", old_io_open) -- Restore _G.vim.cmd to the state it was in before this test modified it. -- The original value (or nil) was stored in original_vim_functions["cmd"] -- by this test's setup logic (around line 472). if _G.vim and rawget(original_vim_functions, "cmd") ~= nil then -- Check if "cmd" key exists in original_vim_functions. -- This implies it was set by this test or a misbehaving prior one. _G.vim.cmd = original_vim_functions["cmd"] -- Nil out this entry to signify this specific override has been reverted, -- preventing the main file teardown (if it runs) from acting on it again -- or a subsequent test from being confused by this stale backup. original_vim_functions["cmd"] = nil end end) it("should handle parameter validation across the protocol", function() -- Use mock tools for parameter validation test tools = { handle_invoke = function(client, params) if params.name == "openDiff" then local required_params = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" } for _, param in ipairs(required_params) do if not params.arguments[param] then return { result = { content = { { type = "text", text = "Error: Missing required parameter: " .. param, }, }, isError = true, }, } end end return { result = { content = { { type = "text", text = "Diff opened successfully", }, }, }, } else return { error = { code = -32601, message = "Tool not found: " .. params.name, }, } end end, } server.register_handlers() -- Replace the tools reference in server handlers server.state.handlers["tools/call"] = function(client, params) local result_or_error_table = tools.handle_invoke(client, params) if result_or_error_table.error then return nil, result_or_error_table.error elseif result_or_error_table.result then return result_or_error_table.result, nil else return nil, { code = -32603, message = "Internal error", data = "Tool handler returned unexpected format", } end end local call_handler = server.state.handlers["tools/call"] local required_params = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" } for _, missing_param in ipairs(required_params) do local params = { name = "openDiff", arguments = { old_file_path = "/test/old.lua", new_file_path = "/test/new.lua", new_file_contents = "content", tab_name = "Test", }, } params.arguments[missing_param] = nil local result, result_error = call_handler(nil, params) expect(result).to_be_table() expect(result_error).to_be_nil() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") expect(type(result.content[1].text)).to_be("string") expect(result.content[1].text:find("Missing required parameter: " .. missing_param)).to_be_truthy() expect(result.isError).to_be_true() end end) end) describe("Authentication Flow Integration", function() local test_auth_token = "550e8400-e29b-41d4-a716-446655440000" local config = { port_range = { min = 10000, max = 65535, }, } -- Ensure clean state before each test before_each(function() if server.state.server then server.stop() end end) -- Clean up after each test after_each(function() if server.state.server then server.stop() end end) it("should start server with auth token", function() -- Start server with authentication local success, port = server.start(config, test_auth_token) expect(success).to_be_true() expect(server.state.auth_token).to_be(test_auth_token) expect(type(port)).to_be("number") -- Verify server is running with auth local status = server.get_status() expect(status.running).to_be_true() expect(status.port).to_be(port) -- Clean up server.stop() end) it("should handle authentication state across server lifecycle", function() -- Start with authentication local success1, _ = server.start(config, test_auth_token) expect(success1).to_be_true() expect(server.state.auth_token).to_be(test_auth_token) -- Stop server server.stop() expect(server.state.auth_token).to_be_nil() -- Start without authentication local success2, _ = server.start(config, nil) expect(success2).to_be_true() expect(server.state.auth_token).to_be_nil() -- Clean up server.stop() end) it("should handle different auth states", function() -- Test with authentication enabled local success1, _ = server.start(config, test_auth_token) expect(success1).to_be_true() expect(server.state.auth_token).to_be(test_auth_token) server.stop() -- Test with authentication disabled local success2, _ = server.start(config, nil) expect(success2).to_be_true() expect(server.state.auth_token).to_be_nil() -- Clean up server.stop() end) it("should preserve auth token during handler setup", function() -- Start server with auth token server.start(config, test_auth_token) expect(server.state.auth_token).to_be(test_auth_token) -- Register handlers - should not affect auth token server.register_handlers() expect(server.state.auth_token).to_be(test_auth_token) -- Get status - should not affect auth token local status = server.get_status() expect(status.running).to_be_true() expect(server.state.auth_token).to_be(test_auth_token) -- Clean up server.stop() end) it("should handle multiple auth token operations", function() -- Start server server.start(config, test_auth_token) expect(server.state.auth_token).to_be(test_auth_token) -- Multiple operations that should not affect auth token for i = 1, 5 do server.register_handlers() local status = server.get_status() expect(status.running).to_be_true() -- Auth token should remain stable expect(server.state.auth_token).to_be(test_auth_token) end -- Clean up server.stop() end) end) teardown() end) ================================================ FILE: tests/mocks/vim.lua ================================================ --- Mock implementation of the Neovim API for tests. --- Spy functionality for testing. --- Provides a `spy.on` method to wrap functions and track their calls. if _G.spy == nil then _G.spy = { on = function(table, method_name) local original = table[method_name] local calls = {} table[method_name] = function(...) table.insert(calls, { vals = { ... } }) if original then return original(...) end end table[method_name].calls = calls table[method_name].spy = function() return { was_called = function(n) assert(#calls == n, "Expected " .. n .. " calls, got " .. #calls) return true end, was_not_called = function() assert(#calls == 0, "Expected 0 calls, got " .. #calls) return true end, was_called_with = function(...) local expected = { ... } assert(#calls > 0, "Function was never called") local last_call = calls[#calls].vals for i, v in ipairs(expected) do if type(v) == "table" and v._type == "match" then -- Use custom matcher (simplified for this mock) if v._match == "is_table" and type(last_call[i]) ~= "table" then assert(false, "Expected table at arg " .. i) end else assert(last_call[i] == v, "Argument mismatch at position " .. i) end end return true end, } end return table[method_name] end, } --- Simple table matcher for spy assertions. --- Allows checking if an argument was a table. _G.match = { is_table = function() return { _type = "match", _match = "is_table" } end, } end local vim = { _buffers = {}, _windows = { [1000] = { buf = 1 } }, -- Initialize with a default window _commands = {}, _autocmds = {}, _vars = {}, _options = {}, _current_window = 1000, api = { nvim_create_user_command = function(name, callback, opts) vim._commands[name] = { callback = callback, opts = opts, } end, nvim_create_augroup = function(name, opts) vim._autocmds[name] = { opts = opts, events = {}, } return name end, nvim_create_autocmd = function(events, opts) local group = opts.group or "default" if not vim._autocmds[group] then vim._autocmds[group] = { opts = {}, events = {}, } end local id = #vim._autocmds[group].events + 1 vim._autocmds[group].events[id] = { events = events, opts = opts, } return id end, nvim_clear_autocmds = function(opts) if opts.group then vim._autocmds[opts.group] = nil end end, nvim_get_current_buf = function() return 1 end, nvim_buf_get_name = function(bufnr) return vim._buffers[bufnr] and vim._buffers[bufnr].name or "" end, nvim_win_get_cursor = function(winid) return vim._windows[winid] and vim._windows[winid].cursor or { 1, 0 } end, nvim_buf_get_lines = function(bufnr, start, end_line, strict) if not vim._buffers[bufnr] then return {} end local lines = vim._buffers[bufnr].lines or {} local result = {} for i = start + 1, end_line do table.insert(result, lines[i] or "") end return result end, nvim_buf_get_option = function(bufnr, name) if not vim._buffers[bufnr] then return nil end return vim._buffers[bufnr].options and vim._buffers[bufnr].options[name] or nil end, nvim_buf_delete = function(bufnr, opts) vim._buffers[bufnr] = nil end, nvim_echo = function(chunks, history, opts) -- Store the last echo message for test assertions. vim._last_echo = { chunks = chunks, history = history, opts = opts, } end, nvim_err_writeln = function(msg) vim._last_error = msg end, nvim_buf_set_name = function(bufnr, name) if vim._buffers[bufnr] then vim._buffers[bufnr].name = name else -- TODO: Consider if error handling for 'buffer not found' is needed for tests. end end, nvim_set_option_value = function(name, value, opts) -- Note: This mock simplifies 'scope = "local"' handling. -- In a real nvim_set_option_value, 'local' scope would apply to a specific -- buffer or window. Here, it's stored in a general options table if not -- a buffer-local option, or in the buffer's options table if `opts.buf` is provided. -- A more complex mock might be needed for intricate scope-related tests. if opts and opts.scope == "local" and opts.buf then if vim._buffers[opts.buf] then if not vim._buffers[opts.buf].options then vim._buffers[opts.buf].options = {} end vim._buffers[opts.buf].options[name] = value else -- TODO: Consider if error handling for 'buffer not found' is needed for tests. end else vim._options[name] = value end end, -- Add missing API functions for diff tests nvim_create_buf = function(listed, scratch) local bufnr = #vim._buffers + 1 vim._buffers[bufnr] = { name = "", lines = {}, options = {}, listed = listed, scratch = scratch, } return bufnr end, nvim_buf_set_lines = function(bufnr, start, end_line, strict_indexing, replacement) if not vim._buffers[bufnr] then vim._buffers[bufnr] = { lines = {}, options = {} } end vim._buffers[bufnr].lines = replacement or {} end, nvim_buf_set_option = function(bufnr, name, value) if not vim._buffers[bufnr] then vim._buffers[bufnr] = { lines = {}, options = {} } end if not vim._buffers[bufnr].options then vim._buffers[bufnr].options = {} end vim._buffers[bufnr].options[name] = value end, nvim_buf_is_valid = function(bufnr) return vim._buffers[bufnr] ~= nil end, nvim_buf_is_loaded = function(bufnr) -- In our mock, all valid buffers are considered loaded return vim._buffers[bufnr] ~= nil end, nvim_list_bufs = function() -- Return a list of buffer IDs local bufs = {} for bufnr, _ in pairs(vim._buffers) do table.insert(bufs, bufnr) end return bufs end, nvim_buf_call = function(bufnr, callback) -- Mock implementation - just call the callback if vim._buffers[bufnr] then return callback() end error("Invalid buffer id: " .. tostring(bufnr)) end, nvim_get_autocmds = function(opts) if opts and opts.group then local group = vim._autocmds[opts.group] if group and group.events then local result = {} for id, event in pairs(group.events) do table.insert(result, { id = id, group = opts.group, event = event.events, pattern = event.opts.pattern, callback = event.opts.callback, }) end return result end end return {} end, nvim_del_autocmd = function(id) -- Find and remove autocmd by id for group_name, group in pairs(vim._autocmds) do if group.events and group.events[id] then group.events[id] = nil return end end end, nvim_get_current_win = function() return 1000 -- Mock window ID end, nvim_set_current_win = function(winid) -- Mock implementation - just track that it was called vim._current_window = winid return true end, nvim_list_wins = function() -- Return a list of window IDs local wins = {} for winid, _ in pairs(vim._windows) do table.insert(wins, winid) end if #wins == 0 then -- Always have at least one window table.insert(wins, 1000) end return wins end, nvim_win_set_buf = function(winid, bufnr) if not vim._windows[winid] then vim._windows[winid] = {} end vim._windows[winid].buf = bufnr end, nvim_win_get_buf = function(winid) if vim._windows[winid] then return vim._windows[winid].buf or 1 end return 1 -- Default buffer end, nvim_win_is_valid = function(winid) return vim._windows[winid] ~= nil end, nvim_win_close = function(winid, force) vim._windows[winid] = nil end, nvim_win_call = function(winid, callback) -- Mock implementation - just call the callback if vim._windows[winid] then return callback() end error("Invalid window id: " .. tostring(winid)) end, nvim_win_get_config = function(winid) -- Mock implementation - return empty config for non-floating windows if vim._windows[winid] then return vim._windows[winid].config or {} end return {} end, nvim_get_current_tabpage = function() return 1 end, nvim_tabpage_set_var = function(tabpage, name, value) -- Mock tabpage variable setting end, }, fn = { getpid = function() return 12345 end, expand = function(path) return path:gsub("~", "/home/user") end, filereadable = function(path) -- Check if file actually exists local file = io.open(path, "r") if file then file:close() return 1 end return 0 end, bufnr = function(name) for bufnr, buf in pairs(vim._buffers) do if buf.name == name then return bufnr end end return -1 end, buflisted = function(bufnr) return vim._buffers[bufnr] and vim._buffers[bufnr].listed and 1 or 0 end, mkdir = function(path, flags) return 1 end, getpos = function(mark) if mark == "'<" then return { 0, 1, 1, 0 } elseif mark == "'>" then return { 0, 1, 10, 0 } end return { 0, 0, 0, 0 } end, mode = function() return "n" end, fnameescape = function(name) return name:gsub(" ", "\\ ") end, getcwd = function() return "/home/user/project" end, fnamemodify = function(path, modifier) if modifier == ":t" then return path:match("([^/]+)$") or path end return path end, has = function(feature) if feature == "nvim-0.8.0" then return 1 end return 0 end, stdpath = function(type) if type == "cache" then return "/tmp/nvim_mock_cache" elseif type == "config" then return "/tmp/nvim_mock_config" elseif type == "data" then return "/tmp/nvim_mock_data" elseif type == "temp" then return "/tmp" else return "/tmp/nvim_mock_stdpath_" .. type end end, tempname = function() -- Return a somewhat predictable temporary name for testing. -- The random number ensures some uniqueness if called multiple times. return "/tmp/nvim_mock_tempfile_" .. math.random(1, 100000) end, writefile = function(lines, filename, flags) -- Mock implementation - just record that it was called vim._written_files = vim._written_files or {} vim._written_files[filename] = lines return 0 end, localtime = function() return os.time() end, }, cmd = function(command) -- Store the last command for test assertions. vim._last_command = command end, json = { encode = function(data) -- Extremely simplified JSON encoding, sufficient for basic test cases. -- Does not handle all JSON types or edge cases. if type(data) == "table" then local parts = {} for k, v in pairs(data) do local val if type(v) == "string" then val = '"' .. v .. '"' elseif type(v) == "table" then val = vim.json.encode(v) else val = tostring(v) end if type(k) == "number" then table.insert(parts, val) else table.insert(parts, '"' .. k .. '":' .. val) end end if #parts > 0 and type(next(data)) == "number" then return "[" .. table.concat(parts, ",") .. "]" else return "{" .. table.concat(parts, ",") .. "}" end elseif type(data) == "string" then return '"' .. data .. '"' else return tostring(data) end end, decode = function(json_str) -- This is a non-functional stub for `vim.json.decode`. -- If tests require actual JSON decoding, a proper library or a more -- sophisticated mock implementation would be necessary. return {} end, }, -- Additional missing vim functions wait = function(timeout, condition, interval, fast_only) -- Optimized mock implementation for faster test execution local start_time = os.clock() interval = interval or 10 -- Reduced from 200ms to 10ms for faster polling timeout = timeout or 1000 while (os.clock() - start_time) * 1000 < timeout do if condition and condition() then return true end -- Add a small sleep to prevent busy-waiting and reduce CPU usage os.execute("sleep 0.001") -- 1ms sleep end return false end, keymap = { set = function(mode, lhs, rhs, opts) -- Mock keymap setting vim._keymaps = vim._keymaps or {} vim._keymaps[mode] = vim._keymaps[mode] or {} vim._keymaps[mode][lhs] = { rhs = rhs, opts = opts } end, }, split = function(str, sep) local result = {} local pattern = "([^" .. sep .. "]+)" for match in str:gmatch(pattern) do table.insert(result, match) end return result end, -- Add tbl_extend function for compatibility tbl_extend = function(behavior, ...) local tables = { ... } local result = {} for _, tbl in ipairs(tables) do for k, v in pairs(tbl) do if behavior == "force" or result[k] == nil then result[k] = v end end end return result end, g = setmetatable({}, { __index = function(_, key) return vim._vars[key] end, __newindex = function(_, key, value) vim._vars[key] = value end, }), b = setmetatable({}, { __index = function(_, bufnr) -- Return buffer-local variables for the given buffer if vim._buffers[bufnr] then if not vim._buffers[bufnr].b_vars then vim._buffers[bufnr].b_vars = {} end return vim._buffers[bufnr].b_vars end return {} end, __newindex = function(_, bufnr, vars) -- Set buffer-local variables for the given buffer if vim._buffers[bufnr] then vim._buffers[bufnr].b_vars = vars end end, }), deepcopy = function(tbl) if type(tbl) ~= "table" then return tbl end local copy = {} for k, v in pairs(tbl) do if type(v) == "table" then copy[k] = vim.deepcopy(v) else copy[k] = v end end return copy end, tbl_deep_extend = function(behavior, ...) local result = {} local tables = { ... } for _, tbl in ipairs(tables) do for k, v in pairs(tbl) do if type(v) == "table" and type(result[k]) == "table" then result[k] = vim.tbl_deep_extend(behavior, result[k], v) else result[k] = v end end end return result end, inspect = function(obj) -- Keep the mock inspect for controlled output if type(obj) == "string" then return '"' .. obj .. '"' elseif type(obj) == "table" then local items = {} local is_array = true local i = 1 for k, _ in pairs(obj) do if k ~= i then is_array = false break end i = i + 1 end if is_array then for _, v_arr in ipairs(obj) do table.insert(items, vim.inspect(v_arr)) end return "{" .. table.concat(items, ", ") .. "}" -- Lua tables are 1-indexed, show as {el1, el2} else -- map-like table for k_map, v_map in pairs(obj) do local key_str if type(k_map) == "string" then key_str = k_map else key_str = "[" .. vim.inspect(k_map) .. "]" end table.insert(items, key_str .. " = " .. vim.inspect(v_map)) end return "{" .. table.concat(items, ", ") .. "}" end elseif type(obj) == "boolean" then return tostring(obj) elseif type(obj) == "number" then return tostring(obj) elseif obj == nil then return "nil" else return type(obj) .. ": " .. tostring(obj) -- Fallback for other types end end, --- Stub for the `vim.loop` module. --- Provides minimal implementations for TCP and timer functionalities --- required by some plugin tests. loop = { new_tcp = function() return { bind = function(self, host, port) return true end, listen = function(self, backlog, callback) return true end, accept = function(self, client) return true end, read_start = function(self, callback) return true end, write = function(self, data, callback) if callback then callback() end return true end, close = function(self) return true end, is_closing = function(self) return false end, } end, new_timer = function() return { start = function(self, timeout, repeat_interval, callback) return true end, stop = function(self) return true end, close = function(self) return true end, } end, now = function() return os.time() * 1000 end, timer_stop = function(timer) return true end, }, schedule = function(callback) callback() end, defer_fn = function(fn, timeout) -- For testing purposes, this mock executes the deferred function immediately -- instead of after a timeout. fn() end, notify = function(msg, level, opts) -- Store the last notification for test assertions. vim._last_notify = { msg = msg, level = level, opts = opts, } -- Return a mock notification ID, as some code might expect a return value. return 1 end, log = { levels = { TRACE = 0, DEBUG = 1, ERROR = 2, WARN = 3, INFO = 4, }, -- Provides log level constants, similar to `vim.log.levels`. -- The actual logging functions (trace, debug, etc.) are no-ops in this mock. -- These are primarily for `vim.notify` level compatibility if used. trace = function(...) end, debug = function(...) end, info = function(...) end, warn = function(...) end, error = function(...) end, }, } -- Helper function to split lines local function split_lines(str) local lines = {} for line in str:gmatch("([^\n]*)\n?") do table.insert(lines, line) end return lines end --- Internal helper functions for tests to manipulate the mock's state. --- These are not part of the Neovim API but are useful for setting up --- specific scenarios for testing plugins. vim._mock = { add_buffer = function(bufnr, name, content, opts) vim._buffers[bufnr] = { name = name, lines = type(content) == "string" and split_lines(content) or content, options = opts or {}, listed = true, } end, split_lines = split_lines, add_window = function(winid, bufnr, cursor) vim._windows[winid] = { buffer = bufnr, cursor = cursor or { 1, 0 }, } end, reset = function() vim._buffers = {} vim._windows = {} vim._commands = {} vim._autocmds = {} vim._vars = {} vim._options = {} vim._last_command = nil vim._last_echo = nil vim._last_error = nil end, } if _G.vim == nil then _G.vim = vim end vim._mock.add_buffer(1, "/home/user/project/test.lua", "local test = {}\nreturn test") vim._mock.add_window(0, 1, { 1, 0 }) return vim ================================================ FILE: tests/unit/at_mention_edge_cases_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("At Mention Edge Cases", function() local init_module local mock_vim local function setup_mocks() package.loaded["claudecode.init"] = nil package.loaded["claudecode.logger"] = nil package.loaded["claudecode.config"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function(component, ...) local args = { ... } local message = table.concat(args, " ") _G.vim.notify(message, _G.vim.log.levels.WARN) end, error = function(component, ...) local args = { ... } local message = table.concat(args, " ") _G.vim.notify(message, _G.vim.log.levels.ERROR) end, } -- Mock config package.loaded["claudecode.config"] = { get = function() return { debounce_ms = 100, visual_demotion_delay_ms = 50, } end, } -- Extend the existing vim mock mock_vim = _G.vim or {} -- Mock file system functions mock_vim.fn = mock_vim.fn or {} mock_vim.fn.isdirectory = function(path) -- Simulate non-existent paths if string.match(path, "nonexistent") or string.match(path, "invalid") then return 0 end if string.match(path, "/lua$") or string.match(path, "/tests$") or path == "/Users/test/project" then return 1 end return 0 end mock_vim.fn.filereadable = function(path) -- Simulate non-existent files if string.match(path, "nonexistent") or string.match(path, "invalid") then return 0 end if string.match(path, "%.lua$") or string.match(path, "%.txt$") then return 1 end return 0 end mock_vim.fn.getcwd = function() return "/Users/test/project" end mock_vim.log = mock_vim.log or {} mock_vim.log.levels = { ERROR = 1, WARN = 2, INFO = 3, } mock_vim.notify = function(message, level) -- Store notifications for testing mock_vim._last_notification = { message = message, level = level } end _G.vim = mock_vim end before_each(function() setup_mocks() init_module = require("claudecode.init") end) describe("format_path_for_at_mention validation", function() it("should reject nil file_path", function() local success, error_msg = pcall(function() return init_module._format_path_for_at_mention(nil) end) expect(success).to_be_false() expect(error_msg).to_be_string() assert_contains(error_msg, "non-empty string") end) it("should reject empty string file_path", function() local success, error_msg = pcall(function() return init_module._format_path_for_at_mention("") end) expect(success).to_be_false() expect(error_msg).to_be_string() assert_contains(error_msg, "non-empty string") end) it("should reject non-string file_path", function() local success, error_msg = pcall(function() return init_module._format_path_for_at_mention(123) end) expect(success).to_be_false() expect(error_msg).to_be_string() assert_contains(error_msg, "non-empty string") end) it("should reject nonexistent file_path in production", function() -- Temporarily simulate production environment local old_busted = package.loaded["busted"] package.loaded["busted"] = nil local success, error_msg = pcall(function() return init_module._format_path_for_at_mention("/nonexistent/path.lua") end) expect(success).to_be_false() expect(error_msg).to_be_string() assert_contains(error_msg, "does not exist") -- Restore test environment package.loaded["busted"] = old_busted end) it("should handle valid file path", function() local success, result = pcall(function() return init_module._format_path_for_at_mention("/Users/test/project/config.lua") end) expect(success).to_be_true() expect(result).to_be("config.lua") end) it("should handle valid directory path", function() local success, result = pcall(function() return init_module._format_path_for_at_mention("/Users/test/project/lua") end) expect(success).to_be_true() expect(result).to_be("lua/") end) end) describe("broadcast_at_mention error handling", function() it("should handle format_path_for_at_mention errors gracefully", function() -- Mock a running server init_module.state = { server = { broadcast = function() return true end, } } -- Temporarily simulate production environment local old_busted = package.loaded["busted"] package.loaded["busted"] = nil local success, error_msg = init_module._broadcast_at_mention("/invalid/nonexistent/path.lua") expect(success).to_be_false() expect(error_msg).to_be_string() assert_contains(error_msg, "does not exist") -- Restore test environment package.loaded["busted"] = old_busted end) it("should handle server not running", function() init_module.state = { server = nil } local success, error_msg = init_module._broadcast_at_mention("/Users/test/project/config.lua") expect(success).to_be_false() expect(error_msg).to_be_string() assert_contains(error_msg, "not running") end) it("should handle broadcast failures", function() -- Mock a server that fails to broadcast init_module.state = { server = { broadcast = function() return false end, } } local success, error_msg = init_module._broadcast_at_mention("/Users/test/project/config.lua") expect(success).to_be_false() expect(error_msg).to_be_string() assert_contains(error_msg, "Failed to broadcast") end) end) describe("add_paths_to_claude error scenarios", function() it("should handle empty file list", function() init_module.state = { server = { broadcast = function() return true end, } } local success_count, total_count = init_module._add_paths_to_claude({}) expect(success_count).to_be(0) expect(total_count).to_be(0) end) it("should handle nil file list", function() init_module.state = { server = { broadcast = function() return true end, } } local success_count, total_count = init_module._add_paths_to_claude(nil) expect(success_count).to_be(0) expect(total_count).to_be(0) end) it("should handle mixed success and failure", function() init_module.state = { server = { broadcast = function(event, params) -- Fail for files with "fail" in the name return not string.match(params.filePath, "fail") end, }, } local files = { "/Users/test/project/success.lua", "/invalid/fail/path.lua", "/Users/test/project/another_success.lua", } local success_count, total_count = init_module._add_paths_to_claude(files, { show_summary = false }) expect(total_count).to_be(3) expect(success_count).to_be(2) -- Two should succeed, one should fail end) it("should provide user notifications for mixed results", function() init_module.state = { server = { broadcast = function(event, params) return not string.match(params.filePath, "fail") end, }, } local files = { "/Users/test/project/success.lua", "/invalid/fail/path.lua", } local success_count, total_count = init_module._add_paths_to_claude(files, { show_summary = true }) expect(total_count).to_be(2) expect(success_count).to_be(1) -- Check that a notification was generated expect(mock_vim._last_notification).to_be_table() expect(mock_vim._last_notification.message).to_be_string() assert_contains(mock_vim._last_notification.message, "Added 1 file") assert_contains(mock_vim._last_notification.message, "1 failed") expect(mock_vim._last_notification.level).to_be(mock_vim.log.levels.WARN) end) it("should handle all failures", function() init_module.state = { server = { broadcast = function() return false end, } } local files = { "/Users/test/project/file1.lua", "/Users/test/project/file2.lua", } local success_count, total_count = init_module._add_paths_to_claude(files, { show_summary = true }) expect(total_count).to_be(2) expect(success_count).to_be(0) -- Check that a notification was generated with ERROR level expect(mock_vim._last_notification).to_be_table() expect(mock_vim._last_notification.level).to_be(mock_vim.log.levels.ERROR) end) end) describe("special path edge cases", function() it("should handle paths with spaces", function() mock_vim.fn.filereadable = function(path) return path == "/Users/test/project/file with spaces.lua" and 1 or 0 end local success, result = pcall(function() return init_module._format_path_for_at_mention("/Users/test/project/file with spaces.lua") end) expect(success).to_be_true() expect(result).to_be("file with spaces.lua") end) it("should handle paths with special characters", function() mock_vim.fn.filereadable = function(path) return path == "/Users/test/project/file-name_test.lua" and 1 or 0 end local success, result = pcall(function() return init_module._format_path_for_at_mention("/Users/test/project/file-name_test.lua") end) expect(success).to_be_true() expect(result).to_be("file-name_test.lua") end) it("should handle very long paths", function() local long_path = "/Users/test/project/" .. string.rep("very_long_directory_name/", 10) .. "file.lua" mock_vim.fn.filereadable = function(path) return path == long_path and 1 or 0 end local success, result = pcall(function() return init_module._format_path_for_at_mention(long_path) end) expect(success).to_be_true() expect(result).to_be_string() assert_contains(result, "file.lua") end) end) end) ================================================ FILE: tests/unit/at_mention_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("At Mention Functionality", function() local init_module local integrations local mock_vim local function setup_mocks() package.loaded["claudecode.init"] = nil package.loaded["claudecode.integrations"] = nil package.loaded["claudecode.logger"] = nil package.loaded["claudecode.config"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function() end, error = function() end, } -- Mock config package.loaded["claudecode.config"] = { get = function() return { debounce_ms = 100, visual_demotion_delay_ms = 50, } end, } -- Extend the existing vim mock instead of replacing it mock_vim = _G.vim or {} -- Add or override specific functions for this test mock_vim.fn = mock_vim.fn or {} mock_vim.fn.isdirectory = function(path) if string.match(path, "/lua$") or string.match(path, "/tests$") or path == "/Users/test/project" then return 1 end return 0 end mock_vim.fn.getcwd = function() return "/Users/test/project" end mock_vim.fn.mode = function() return "n" end mock_vim.api = mock_vim.api or {} mock_vim.api.nvim_get_current_win = function() return 1002 end mock_vim.api.nvim_get_mode = function() return { mode = "n" } end mock_vim.api.nvim_get_current_buf = function() return 1 end mock_vim.bo = { filetype = "neo-tree" } mock_vim.schedule = function(fn) fn() end _G.vim = mock_vim end before_each(function() setup_mocks() end) describe("file at mention from neo-tree", function() before_each(function() integrations = require("claudecode.integrations") init_module = require("claudecode.init") end) it("should format single file path correctly", function() local mock_state = { tree = { get_node = function() return { type = "file", path = "/Users/test/project/lua/init.lua", } end, }, } package.loaded["neo-tree.sources.manager"] = { get_state = function() return mock_state end, } local files, err = integrations._get_neotree_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/lua/init.lua") end) it("should format directory path with trailing slash", function() local mock_state = { tree = { get_node = function() return { type = "directory", path = "/Users/test/project/lua", } end, }, } package.loaded["neo-tree.sources.manager"] = { get_state = function() return mock_state end, } local files, err = integrations._get_neotree_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/lua") local formatted_path = init_module._format_path_for_at_mention(files[1]) expect(formatted_path).to_be("lua/") end) it("should handle relative path conversion", function() local file_path = "/Users/test/project/lua/config.lua" local formatted_path = init_module._format_path_for_at_mention(file_path) expect(formatted_path).to_be("lua/config.lua") end) it("should handle root project directory", function() local dir_path = "/Users/test/project" local formatted_path = init_module._format_path_for_at_mention(dir_path) expect(formatted_path).to_be("./") end) end) describe("file at mention from nvim-tree", function() before_each(function() integrations = require("claudecode.integrations") init_module = require("claudecode.init") end) it("should get selected file from nvim-tree", function() package.loaded["nvim-tree.api"] = { tree = { get_node_under_cursor = function() return { type = "file", absolute_path = "/Users/test/project/tests/test_spec.lua", } end, }, marks = { list = function() return {} end, }, } mock_vim.bo.filetype = "NvimTree" local files, err = integrations._get_nvim_tree_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/tests/test_spec.lua") end) it("should get selected directory from nvim-tree", function() package.loaded["nvim-tree.api"] = { tree = { get_node_under_cursor = function() return { type = "directory", absolute_path = "/Users/test/project/tests", } end, }, marks = { list = function() return {} end, }, } mock_vim.bo.filetype = "NvimTree" local files, err = integrations._get_nvim_tree_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/tests") local formatted_path = init_module._format_path_for_at_mention(files[1]) expect(formatted_path).to_be("tests/") end) it("should handle multiple marked files in nvim-tree", function() package.loaded["nvim-tree.api"] = { tree = { get_node_under_cursor = function() return { type = "file", absolute_path = "/Users/test/project/init.lua", } end, }, marks = { list = function() return { { type = "file", absolute_path = "/Users/test/project/config.lua" }, { type = "file", absolute_path = "/Users/test/project/utils.lua" }, } end, }, } mock_vim.bo.filetype = "NvimTree" local files, err = integrations._get_nvim_tree_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(2) expect(files[1]).to_be("/Users/test/project/config.lua") expect(files[2]).to_be("/Users/test/project/utils.lua") end) end) describe("at mention error handling", function() before_each(function() integrations = require("claudecode.integrations") end) it("should handle unsupported buffer types", function() mock_vim.bo.filetype = "text" local files, err = integrations.get_selected_files_from_tree() expect(files).to_be_nil() expect(err).to_be_string() assert_contains(err, "supported") end) it("should handle neo-tree errors gracefully", function() mock_vim.bo.filetype = "neo-tree" package.loaded["neo-tree.sources.manager"] = { get_state = function() error("Neo-tree not initialized") end, } local success, result_or_error = pcall(function() return integrations._get_neotree_selection() end) expect(success).to_be_false() expect(result_or_error).to_be_string() assert_contains(result_or_error, "Neo-tree not initialized") end) it("should handle nvim-tree errors gracefully", function() mock_vim.bo.filetype = "NvimTree" package.loaded["nvim-tree.api"] = { tree = { get_node_under_cursor = function() error("NvimTree not available") end, }, marks = { list = function() return {} end, }, } local success, result_or_error = pcall(function() return integrations._get_nvim_tree_selection() end) expect(success).to_be_false() expect(result_or_error).to_be_string() assert_contains(result_or_error, "NvimTree not available") end) end) describe("integration with main module", function() before_each(function() integrations = require("claudecode.integrations") init_module = require("claudecode.init") end) it("should send files to Claude via at mention", function() local sent_files = {} init_module._test_send_at_mention = function(files) sent_files = files end local mock_state = { tree = { get_node = function() return { type = "file", path = "/Users/test/project/src/main.lua", } end, }, } package.loaded["neo-tree.sources.manager"] = { get_state = function() return mock_state end, } local files, err = integrations.get_selected_files_from_tree() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) if init_module._test_send_at_mention then init_module._test_send_at_mention(files) end expect(#sent_files).to_be(1) expect(sent_files[1]).to_be("/Users/test/project/src/main.lua") end) it("should handle mixed file and directory selection", function() local mixed_files = { "/Users/test/project/init.lua", "/Users/test/project/lua", "/Users/test/project/config.lua", } local formatted_files = {} for _, file_path in ipairs(mixed_files) do local formatted_path = init_module._format_path_for_at_mention(file_path) table.insert(formatted_files, formatted_path) end expect(#formatted_files).to_be(3) expect(formatted_files[1]).to_be("init.lua") expect(formatted_files[2]).to_be("lua/") expect(formatted_files[3]).to_be("config.lua") end) end) end) ================================================ FILE: tests/unit/claudecode_add_command_spec.lua ================================================ require("tests.busted_setup") require("tests.mocks.vim") describe("ClaudeCodeAdd command", function() local claudecode local mock_server local mock_logger local saved_require = _G.require local function setup_mocks() mock_server = { broadcast = spy.new(function() return true end), } mock_logger = { setup = function() end, debug = spy.new(function() end), error = spy.new(function() end), warn = spy.new(function() end), } -- Override vim.fn functions for our specific tests vim.fn.expand = spy.new(function(path) if path == "~/test.lua" then return "/home/user/test.lua" elseif path == "./relative.lua" then return "/current/dir/relative.lua" end return path end) vim.fn.filereadable = spy.new(function(path) if path == "/existing/file.lua" or path == "/home/user/test.lua" or path == "/current/dir/relative.lua" then return 1 end return 0 end) vim.fn.isdirectory = spy.new(function(path) if path == "/existing/dir" then return 1 end return 0 end) vim.fn.getcwd = function() return "/current/dir" end vim.fn.getbufinfo = function(bufnr) return { { windows = { 1 } } } end vim.api.nvim_create_user_command = spy.new(function() end) vim.api.nvim_buf_get_name = function() return "test.lua" end vim.bo = { filetype = "lua" } vim.notify = spy.new(function() end) _G.require = function(mod) if mod == "claudecode.logger" then return mock_logger elseif mod == "claudecode.config" then return { apply = function(opts) return opts or {} end, } elseif mod == "claudecode.diff" then return { setup = function() end, } elseif mod == "claudecode.server.init" then return { get_status = function() return { running = true, client_count = 1 } end, } elseif mod == "claudecode.terminal" then return { setup = function() end, open = spy.new(function() end), toggle_open_no_focus = spy.new(function() end), ensure_visible = spy.new(function() end), get_active_terminal_bufnr = function() return 1 end, simple_toggle = spy.new(function() end), } elseif mod == "claudecode.visual_commands" then return { create_visual_command_wrapper = function(normal_handler, visual_handler) return normal_handler end, } else return saved_require(mod) end end end before_each(function() setup_mocks() -- Clear package cache to ensure fresh require package.loaded["claudecode"] = nil package.loaded["claudecode.config"] = nil package.loaded["claudecode.logger"] = nil package.loaded["claudecode.diff"] = nil package.loaded["claudecode.visual_commands"] = nil package.loaded["claudecode.terminal"] = nil claudecode = require("claudecode") -- Set up the server state manually for testing claudecode.state.server = mock_server claudecode.state.port = 12345 end) after_each(function() _G.require = saved_require package.loaded["claudecode"] = nil end) describe("command registration", function() it("should register ClaudeCodeAdd command during setup", function() claudecode.setup({ auto_start = false }) -- Find the ClaudeCodeAdd command registration local add_command_found = false for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeAdd" then add_command_found = true local config = call.vals[3] assert.is_equal("+", config.nargs) assert.is_equal("file", config.complete) assert.is_string(config.desc) assert.is_true(string.find(config.desc, "line range") ~= nil, "Description should mention line range support") break end end assert.is_true(add_command_found, "ClaudeCodeAdd command was not registered") end) end) describe("command execution", function() local command_handler before_each(function() claudecode.setup({ auto_start = false }) for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeAdd" then command_handler = call.vals[2] break end end assert.is_function(command_handler, "Command handler should be a function") end) describe("validation", function() it("should error when server is not running", function() claudecode.state.server = nil command_handler({ args = "/existing/file.lua" }) assert.spy(mock_logger.error).was_called() end) it("should error when no file path is provided", function() command_handler({ args = "" }) assert.spy(mock_logger.error).was_called() end) it("should error when file does not exist", function() command_handler({ args = "/nonexistent/file.lua" }) assert.spy(mock_logger.error).was_called() end) end) describe("path handling", function() it("should expand tilde paths", function() command_handler({ args = "~/test.lua" }) assert.spy(vim.fn.expand).was_called_with("~/test.lua") assert.spy(mock_server.broadcast).was_called() end) it("should expand relative paths", function() command_handler({ args = "./relative.lua" }) assert.spy(vim.fn.expand).was_called_with("./relative.lua") assert.spy(mock_server.broadcast).was_called() end) it("should handle absolute paths", function() command_handler({ args = "/existing/file.lua" }) assert.spy(mock_server.broadcast).was_called() end) end) describe("broadcasting", function() it("should broadcast existing file successfully", function() command_handler({ args = "/existing/file.lua" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/file.lua", lineStart = nil, lineEnd = nil, }) assert.spy(mock_logger.debug).was_called() end) it("should broadcast existing directory successfully", function() command_handler({ args = "/existing/dir" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/dir/", lineStart = nil, lineEnd = nil, }) assert.spy(mock_logger.debug).was_called() end) it("should handle broadcast failure", function() mock_server.broadcast = spy.new(function() return false end) command_handler({ args = "/existing/file.lua" }) assert.spy(mock_logger.error).was_called() end) end) describe("path formatting", function() it("should handle file broadcasting correctly", function() -- Set up a file that exists vim.fn.filereadable = spy.new(function(path) return path == "/current/dir/src/test.lua" and 1 or 0 end) command_handler({ args = "/current/dir/src/test.lua" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", match.is_table()) assert.spy(mock_logger.debug).was_called() end) it("should add trailing slash for directories", function() command_handler({ args = "/existing/dir" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/dir/", lineStart = nil, lineEnd = nil, }) end) end) describe("line number conversion", function() it("should convert 1-indexed user input to 0-indexed for Claude", function() command_handler({ args = "/existing/file.lua 1 3" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/file.lua", lineStart = 0, lineEnd = 2, }) end) end) describe("line range functionality", function() describe("argument parsing", function() it("should parse single file path correctly", function() command_handler({ args = "/existing/file.lua" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/file.lua", lineStart = nil, lineEnd = nil, }) end) it("should parse file path with start line", function() command_handler({ args = "/existing/file.lua 50" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/file.lua", lineStart = 49, lineEnd = nil, }) end) it("should parse file path with start and end lines", function() command_handler({ args = "/existing/file.lua 50 100" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/file.lua", lineStart = 49, lineEnd = 99, }) end) end) describe("line number validation", function() it("should error on invalid start line number", function() command_handler({ args = "/existing/file.lua abc" }) assert.spy(mock_logger.error).was_called() assert.spy(mock_server.broadcast).was_not_called() end) it("should error on invalid end line number", function() command_handler({ args = "/existing/file.lua 50 xyz" }) assert.spy(mock_logger.error).was_called() assert.spy(mock_server.broadcast).was_not_called() end) it("should error on negative start line", function() command_handler({ args = "/existing/file.lua -5" }) assert.spy(mock_logger.error).was_called() assert.spy(mock_server.broadcast).was_not_called() end) it("should error on negative end line", function() command_handler({ args = "/existing/file.lua 10 -20" }) assert.spy(mock_logger.error).was_called() assert.spy(mock_server.broadcast).was_not_called() end) it("should error on zero line numbers", function() command_handler({ args = "/existing/file.lua 0 10" }) assert.spy(mock_logger.error).was_called() assert.spy(mock_server.broadcast).was_not_called() end) it("should error when start line > end line", function() command_handler({ args = "/existing/file.lua 100 50" }) assert.spy(mock_logger.error).was_called() assert.spy(mock_server.broadcast).was_not_called() end) it("should error on too many arguments", function() command_handler({ args = "/existing/file.lua 10 20 30" }) assert.spy(mock_logger.error).was_called() assert.spy(mock_server.broadcast).was_not_called() end) end) describe("directory handling with line numbers", function() it("should ignore line numbers for directories and warn", function() command_handler({ args = "/existing/dir 50 100" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/dir/", lineStart = nil, lineEnd = nil, }) assert.spy(mock_logger.debug).was_called() end) end) describe("valid line range scenarios", function() it("should handle start line equal to end line", function() command_handler({ args = "/existing/file.lua 50 50" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/file.lua", lineStart = 49, lineEnd = 49, -- 50 - 1 (converted to 0-indexed) }) end) it("should handle large line numbers", function() command_handler({ args = "/existing/file.lua 1000 2000" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/file.lua", lineStart = 999, lineEnd = 1999, }) end) it("should handle single line specification", function() command_handler({ args = "/existing/file.lua 42" }) assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/existing/file.lua", lineStart = 41, lineEnd = nil, }) end) end) describe("path expansion with line ranges", function() it("should expand tilde paths with line numbers", function() command_handler({ args = "~/test.lua 10 20" }) assert.spy(vim.fn.expand).was_called_with("~/test.lua") assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "/home/user/test.lua", lineStart = 9, lineEnd = 19, }) end) it("should expand relative paths with line numbers", function() command_handler({ args = "./relative.lua 5" }) assert.spy(vim.fn.expand).was_called_with("./relative.lua") assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { filePath = "relative.lua", lineStart = 4, lineEnd = nil, }) end) end) end) end) describe("integration with broadcast functions", function() it("should use the extracted broadcast_at_mention function", function() -- This test ensures that the command uses the centralized function -- rather than duplicating broadcast logic claudecode.setup({ auto_start = false }) local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeAdd" then command_handler = call.vals[2] break end end -- Mock the _format_path_for_at_mention function to verify it's called local original_format = claudecode._format_path_for_at_mention claudecode._format_path_for_at_mention = spy.new(function(path) return path, false end) command_handler({ args = "/existing/file.lua" }) assert.spy(mock_server.broadcast).was_called() -- Restore original function claudecode._format_path_for_at_mention = original_format end) end) end) ================================================ FILE: tests/unit/claudecode_send_command_spec.lua ================================================ require("tests.busted_setup") require("tests.mocks.vim") describe("ClaudeCodeSend Command Range Functionality", function() local claudecode local mock_selection_module local mock_server local mock_terminal local command_callback local original_require before_each(function() -- Reset package cache package.loaded["claudecode"] = nil package.loaded["claudecode.selection"] = nil package.loaded["claudecode.terminal"] = nil package.loaded["claudecode.server.init"] = nil package.loaded["claudecode.lockfile"] = nil package.loaded["claudecode.config"] = nil package.loaded["claudecode.logger"] = nil package.loaded["claudecode.diff"] = nil -- Mock vim API _G.vim = { api = { nvim_create_user_command = spy.new(function(name, callback, opts) if name == "ClaudeCodeSend" then command_callback = callback end end), nvim_create_augroup = spy.new(function() return "test_group" end), nvim_create_autocmd = spy.new(function() return 1 end), nvim_feedkeys = spy.new(function() end), nvim_replace_termcodes = spy.new(function(str) return str end), }, notify = spy.new(function() end), log = { levels = { ERROR = 1, WARN = 2, INFO = 3 } }, deepcopy = function(t) return t end, tbl_deep_extend = function(behavior, ...) local result = {} for _, tbl in ipairs({ ... }) do for k, v in pairs(tbl) do result[k] = v end end return result end, fn = { mode = spy.new(function() return "n" end), }, } -- Mock selection module mock_selection_module = { send_at_mention_for_visual_selection = spy.new(function(line1, line2) mock_selection_module.last_call = { line1 = line1, line2 = line2 } return true end), } -- Mock terminal module mock_terminal = { open = spy.new(function() end), ensure_visible = spy.new(function() end), } -- Mock server mock_server = { start = function() return true, 12345 end, stop = function() return true end, } -- Mock other modules local mock_lockfile = { create = function() return true, "/mock/path" end, remove = function() return true end, } local mock_config = { apply = function(opts) return { auto_start = false, track_selection = true, visual_demotion_delay_ms = 200, log_level = "info", } end, } local mock_logger = { setup = function() end, debug = function() end, error = function() end, warn = function() end, } local mock_diff = { setup = function() end, } -- Setup require mocks BEFORE requiring claudecode original_require = _G.require _G.require = function(module_name) if module_name == "claudecode.selection" then return mock_selection_module elseif module_name == "claudecode.terminal" then return mock_terminal elseif module_name == "claudecode.server.init" then return mock_server elseif module_name == "claudecode.lockfile" then return mock_lockfile elseif module_name == "claudecode.config" then return mock_config elseif module_name == "claudecode.logger" then return mock_logger elseif module_name == "claudecode.diff" then return mock_diff else return original_require(module_name) end end -- Load and setup claudecode claudecode = require("claudecode") claudecode.setup({}) -- Manually set server state for testing claudecode.state.server = mock_server claudecode.state.port = 12345 end) after_each(function() -- Restore original require _G.require = original_require end) describe("ClaudeCodeSend command", function() it("should be registered with range support", function() assert.spy(_G.vim.api.nvim_create_user_command).was_called() -- Find the ClaudeCodeSend command call local calls = _G.vim.api.nvim_create_user_command.calls local claudecode_send_call = nil for _, call in ipairs(calls) do if call.vals[1] == "ClaudeCodeSend" then claudecode_send_call = call break end end assert(claudecode_send_call ~= nil, "ClaudeCodeSend command should be registered") assert(claudecode_send_call.vals[3].range == true, "ClaudeCodeSend should support ranges") end) it("should pass range information to selection module when range is provided", function() assert(command_callback ~= nil, "Command callback should be set") -- Simulate command called with range local opts = { range = 2, line1 = 5, line2 = 8, } command_callback(opts) assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() assert(mock_selection_module.last_call.line1 == 5) assert(mock_selection_module.last_call.line2 == 8) end) it("should not pass range information when range is 0", function() assert(command_callback ~= nil, "Command callback should be set") -- Simulate command called without range local opts = { range = 0, line1 = 1, line2 = 1, } command_callback(opts) assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() assert(mock_selection_module.last_call.line1 == nil) assert(mock_selection_module.last_call.line2 == nil) end) it("should not pass range information when range is nil", function() assert(command_callback ~= nil, "Command callback should be set") -- Simulate command called without range local opts = {} command_callback(opts) assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() assert(mock_selection_module.last_call.line1 == nil) assert(mock_selection_module.last_call.line2 == nil) end) it("should exit visual mode on successful send", function() assert(command_callback ~= nil, "Command callback should be set") local opts = { range = 2, line1 = 5, line2 = 8, } command_callback(opts) assert.spy(_G.vim.api.nvim_feedkeys).was_called() -- Terminal should not be automatically opened assert.spy(mock_terminal.open).was_not_called() end) it("should handle server not running", function() assert(command_callback ~= nil, "Command callback should be set") -- Simulate server not running claudecode.state.server = nil local opts = { range = 2, line1 = 5, line2 = 8, } command_callback(opts) -- The command should call the selection module, which will handle the error assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() end) it("should handle selection module failure", function() assert(command_callback ~= nil, "Command callback should be set") -- Mock selection module to return false mock_selection_module.send_at_mention_for_visual_selection = spy.new(function() return false end) local opts = { range = 2, line1 = 5, line2 = 8, } command_callback(opts) assert.spy(mock_selection_module.send_at_mention_for_visual_selection).was_called() -- Should not exit visual mode or focus terminal on failure assert.spy(_G.vim.api.nvim_feedkeys).was_not_called() assert.spy(mock_terminal.open).was_not_called() end) end) end) ================================================ FILE: tests/unit/config_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("Configuration", function() local config local function setup() package.loaded["claudecode.config"] = nil config = require("claudecode.config") end local function teardown() -- Nothing to clean up for now end setup() it("should have default configuration", function() expect(config.defaults).to_be_table() expect(config.defaults).to_have_key("port_range") expect(config.defaults).to_have_key("auto_start") expect(config.defaults).to_have_key("log_level") expect(config.defaults).to_have_key("track_selection") expect(config.defaults).to_have_key("models") expect(config.defaults).to_have_key("diff_opts") expect(config.defaults.diff_opts).to_have_key("keep_terminal_focus") expect(config.defaults.diff_opts.keep_terminal_focus).to_be_false() end) it("should apply and validate user configuration", function() local user_config = { terminal_cmd = "toggleterm", log_level = "debug", track_selection = false, models = { { name = "Test Model", value = "test-model" }, }, } local final_config = config.apply(user_config) expect(final_config).to_be_table() expect(final_config.terminal_cmd).to_be("toggleterm") expect(final_config.log_level).to_be("debug") expect(final_config.track_selection).to_be_false() expect(final_config.env).to_be_table() -- Should inherit default empty table end) it("should reject invalid port range", function() local invalid_config = { port_range = { min = -1, max = 65536 }, auto_start = true, log_level = "debug", track_selection = false, } local success, _ = pcall(function() -- Use _ for unused error variable config.validate(invalid_config) end) expect(success).to_be_false() end) it("should reject invalid log level", function() local invalid_config = { port_range = { min = 10000, max = 65535 }, auto_start = true, log_level = "invalid_level", track_selection = false, } local success, _ = pcall(function() -- Use _ for unused error variable config.validate(invalid_config) end) expect(success).to_be_false() end) it("should reject invalid models configuration", function() local invalid_config = { port_range = { min = 10000, max = 65535 }, auto_start = true, log_level = "debug", track_selection = false, visual_demotion_delay_ms = 50, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, vertical_split = true, open_in_current_tab = true, }, models = {}, -- Empty models array should be rejected } local success, _ = pcall(function() config.validate(invalid_config) end) expect(success).to_be_false() end) it("should reject models with invalid structure", function() local invalid_config = { port_range = { min = 10000, max = 65535 }, auto_start = true, log_level = "debug", track_selection = false, visual_demotion_delay_ms = 50, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, vertical_split = true, open_in_current_tab = true, }, models = { { name = "Test Model" }, -- Missing value field }, } local success, _ = pcall(function() config.validate(invalid_config) end) expect(success).to_be_false() end) it("should merge user config with defaults", function() local user_config = { auto_start = true, log_level = "debug", } local merged_config = config.apply(user_config) expect(merged_config.auto_start).to_be_true() expect(merged_config.log_level).to_be("debug") expect(merged_config.port_range.min).to_be(config.defaults.port_range.min) expect(merged_config.track_selection).to_be(config.defaults.track_selection) expect(merged_config.models).to_be_table() end) it("should accept valid keep_terminal_focus configuration", function() local user_config = { port_range = { min = 10000, max = 65535 }, auto_start = true, log_level = "info", track_selection = true, visual_demotion_delay_ms = 50, connection_wait_delay = 200, connection_timeout = 10000, queue_timeout = 5000, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, vertical_split = true, open_in_current_tab = true, keep_terminal_focus = true, }, env = {}, models = { { name = "Test Model", value = "test" }, }, } local final_config = config.apply(user_config) expect(final_config.diff_opts.keep_terminal_focus).to_be_true() end) it("should reject invalid keep_terminal_focus configuration", function() local invalid_config = { port_range = { min = 10000, max = 65535 }, auto_start = true, log_level = "info", track_selection = true, visual_demotion_delay_ms = 50, connection_wait_delay = 200, connection_timeout = 10000, queue_timeout = 5000, diff_opts = { auto_close_on_accept = true, show_diff_stats = true, vertical_split = true, open_in_current_tab = true, keep_terminal_focus = "invalid", -- Should be boolean }, env = {}, models = { { name = "Test Model", value = "test" }, }, } local success, _ = pcall(function() config.validate(invalid_config) end) expect(success).to_be_false() end) teardown() end) ================================================ FILE: tests/unit/diff_buffer_cleanup_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("Diff Buffer Cleanup Edge Cases", function() local diff_module local mock_vim local function setup_mocks() package.loaded["claudecode.diff"] = nil package.loaded["claudecode.logger"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function() end, error = function() end, } -- Extend the existing vim mock mock_vim = _G.vim or {} -- Track created buffers for cleanup verification mock_vim._created_buffers = {} mock_vim._deleted_buffers = {} -- Mock vim.api functions mock_vim.api = mock_vim.api or {} -- Mock buffer creation with failure simulation mock_vim.api.nvim_create_buf = function(listed, scratch) local buffer_id = #mock_vim._created_buffers + 1000 -- Simulate buffer creation failure if mock_vim._simulate_buffer_creation_failure then return 0 -- Invalid buffer ID end table.insert(mock_vim._created_buffers, buffer_id) return buffer_id end -- Mock buffer deletion tracking mock_vim.api.nvim_buf_delete = function(buf, opts) if mock_vim._simulate_buffer_delete_failure then error("Failed to delete buffer " .. buf) end table.insert(mock_vim._deleted_buffers, buf) end -- Mock buffer validation mock_vim.api.nvim_buf_is_valid = function(buf) -- Buffer is valid if it was created and not deleted for _, created_buf in ipairs(mock_vim._created_buffers) do if created_buf == buf then for _, deleted_buf in ipairs(mock_vim._deleted_buffers) do if deleted_buf == buf then return false end end return true end end return false end -- Mock buffer property setting with failure simulation mock_vim.api.nvim_buf_set_name = function(buf, name) if mock_vim._simulate_buffer_config_failure then error("Failed to set buffer name") end end mock_vim.api.nvim_buf_set_lines = function(buf, start, end_line, strict_indexing, replacement) if mock_vim._simulate_buffer_config_failure then error("Failed to set buffer lines") end end mock_vim.api.nvim_buf_set_option = function(buf, option, value) if mock_vim._simulate_buffer_config_failure then error("Failed to set buffer option: " .. option) end end -- Mock file system functions mock_vim.fn = mock_vim.fn or {} mock_vim.fn.filereadable = function(path) if string.match(path, "nonexistent") then return 0 end return 1 end mock_vim.fn.isdirectory = function(path) return 0 -- Default to file, not directory end mock_vim.fn.fnameescape = function(path) return "'" .. path .. "'" end mock_vim.fn.fnamemodify = function(path, modifier) if modifier == ":h" then return "/parent/dir" end return path end mock_vim.fn.mkdir = function(path, flags) if mock_vim._simulate_mkdir_failure then error("Permission denied") end end -- Mock window functions mock_vim.api.nvim_win_set_buf = function(win, buf) end mock_vim.api.nvim_get_current_win = function() return 1001 end -- Mock command execution mock_vim.cmd = function(command) end _G.vim = mock_vim end before_each(function() setup_mocks() diff_module = require("claudecode.diff") end) describe("buffer creation failure handling", function() it("should handle buffer creation failure", function() mock_vim._simulate_buffer_creation_failure = true local success, error_result = pcall(function() return diff_module._create_diff_view_from_window(1001, "/test/new_file.lua", 2001, "test-diff", true) end) expect(success).to_be_false() expect(error_result).to_be_table() expect(error_result.code).to_be(-32000) expect(error_result.message).to_be("Buffer creation failed") assert_contains(error_result.data, "Failed to create empty buffer") end) it("should clean up buffer on configuration failure", function() mock_vim._simulate_buffer_config_failure = true mock_vim._simulate_buffer_creation_failure = false -- Ensure buffer creation succeeds local success, error_result = pcall(function() return diff_module._create_diff_view_from_window(1001, "/test/new_file.lua", 2001, "test-diff", true) end) expect(success).to_be_false() expect(error_result).to_be_table() expect(error_result.code).to_be(-32000) -- Buffer creation succeeds but configuration fails expect(error_result.message).to_be("Buffer configuration failed") -- Verify buffer was created and then deleted expect(#mock_vim._created_buffers).to_be(1) expect(#mock_vim._deleted_buffers).to_be(1) expect(mock_vim._deleted_buffers[1]).to_be(mock_vim._created_buffers[1]) end) it("should handle buffer cleanup failure gracefully", function() mock_vim._simulate_buffer_config_failure = true mock_vim._simulate_buffer_creation_failure = false -- Ensure buffer creation succeeds mock_vim._simulate_buffer_delete_failure = true local success, error_result = pcall(function() return diff_module._create_diff_view_from_window(1001, "/test/new_file.lua", 2001, "test-diff", true) end) expect(success).to_be_false() expect(error_result).to_be_table() expect(error_result.code).to_be(-32000) expect(error_result.message).to_be("Buffer configuration failed") -- Verify buffer was created but deletion failed expect(#mock_vim._created_buffers).to_be(1) expect(#mock_vim._deleted_buffers).to_be(0) -- Deletion failed end) end) describe("setup error handling with cleanup", function() it("should clean up on setup failure", function() -- Mock a diff setup that will fail local tab_name = "test-diff-fail" local params = { old_file_path = "/nonexistent/path.lua", new_file_path = "/test/new.lua", new_file_contents = "test content", tab_name = tab_name, } -- Mock file existence check to return false mock_vim.fn.filereadable = function(path) return 0 -- File doesn't exist end -- Setup should fail but cleanup should be called local success, error_result = pcall(function() diff_module._setup_blocking_diff(params, function() end) end) expect(success).to_be_false() -- The error should be wrapped in our error handling expect(error_result).to_be_table() expect(error_result.code).to_be(-32000) expect(error_result.message).to_be("Diff setup failed") end) it("should handle directory creation failure for new files", function() local tab_name = "test-new-file" local params = { old_file_path = "/test/subdir/new_file.lua", new_file_path = "/test/subdir/new_file.lua", new_file_contents = "new file content", tab_name = tab_name, } -- Simulate new file (doesn't exist) mock_vim.fn.filereadable = function(path) return path ~= "/test/subdir/new_file.lua" and 1 or 0 end -- Mock mkdir failure during accept operation mock_vim._simulate_mkdir_failure = true -- The setup itself should work, but directory creation will fail later local success, error_result = pcall(function() diff_module._setup_blocking_diff(params, function() end) end) -- Setup should succeed initially if not success then -- If it fails due to our current mocking limitations, that's expected expect(error_result).to_be_table() end end) end) describe("cleanup function robustness", function() it("should handle cleanup of invalid buffers gracefully", function() -- Create a fake diff state with invalid buffer local tab_name = "test-cleanup" local fake_diff_data = { new_buffer = 9999, -- Non-existent buffer new_window = 8888, -- Non-existent window target_window = 7777, autocmd_ids = {}, } -- Store fake diff state diff_module._register_diff_state(tab_name, fake_diff_data) -- Cleanup should not error even with invalid references local success = pcall(function() diff_module._cleanup_diff_state(tab_name, "test cleanup") end) expect(success).to_be_true() end) it("should handle cleanup all diffs", function() -- Create multiple fake diff states local fake_diff_data1 = { new_buffer = 1001, new_window = 2001, target_window = 3001, autocmd_ids = {}, } local fake_diff_data2 = { new_buffer = 1002, new_window = 2002, target_window = 3002, autocmd_ids = {}, } diff_module._register_diff_state("test-diff-1", fake_diff_data1) diff_module._register_diff_state("test-diff-2", fake_diff_data2) -- Cleanup all should not error local success = pcall(function() diff_module._cleanup_all_active_diffs("test cleanup all") end) expect(success).to_be_true() end) end) describe("memory leak prevention", function() it("should not leave orphaned buffers after successful operation", function() local tab_name = "test-memory-leak" local params = { old_file_path = "/test/existing.lua", new_file_path = "/test/new.lua", new_file_contents = "content", tab_name = tab_name, } -- Mock successful setup mock_vim.fn.filereadable = function(path) return path == "/test/existing.lua" and 1 or 0 end -- Try to setup (may fail due to mocking limitations, but shouldn't leak) pcall(function() diff_module._setup_blocking_diff(params, function() end) end) -- Clean up explicitly pcall(function() diff_module._cleanup_diff_state(tab_name, "test complete") end) -- Any created buffers should be cleaned up local buffers_after_cleanup = 0 for _, buf in ipairs(mock_vim._created_buffers) do local was_deleted = false for _, deleted_buf in ipairs(mock_vim._deleted_buffers) do if deleted_buf == buf then was_deleted = true break end end if not was_deleted then buffers_after_cleanup = buffers_after_cleanup + 1 end end -- Should have minimal orphaned buffers (ideally 0, but mocking may cause some) expect(buffers_after_cleanup <= 1).to_be_true() end) end) end) ================================================ FILE: tests/unit/diff_mcp_spec.lua ================================================ --- Tests for MCP-compliant openDiff blocking behavior require("tests.busted_setup") local diff = require("claudecode.diff") describe("MCP-compliant diff operations", function() local test_old_file = "/tmp/test_old_file.txt" local test_new_file = "/tmp/test_new_file.txt" local test_content_old = "line 1\nline 2\noriginal content" local test_content_new = "line 1\nline 2\nnew content\nextra line" local test_tab_name = "test_diff_tab" before_each(function() -- Create test files local file = io.open(test_old_file, "w") file:write(test_content_old) file:close() end) after_each(function() -- Clean up test files os.remove(test_old_file) -- Clean up any active diffs diff._cleanup_all_active_diffs("test_cleanup") end) describe("open_diff_blocking", function() it("should error when not in coroutine context", function() local success, err = pcall(diff.open_diff_blocking, test_old_file, test_new_file, test_content_new, test_tab_name) assert.is_false(success) assert.is_table(err) assert.equal(-32000, err.code) assert_contains(err.data, "openDiff must run in coroutine context") end) it("should create MCP-compliant response on file save", function() local result = nil local co = coroutine.create(function() result = diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, test_tab_name) end) -- Start the coroutine local success, err = coroutine.resume(co) assert.is_true(success, "Coroutine should start successfully: " .. tostring(err)) assert.equal("suspended", coroutine.status(co), "Coroutine should be suspended waiting for user action") -- Simulate file save vim.schedule(function() diff._resolve_diff_as_saved(test_tab_name, 1) -- Mock buffer ID end) -- Wait for resolution vim.wait(100, function() -- Reduced from 1000ms to 100ms return coroutine.status(co) == "dead" end) assert.is_not_nil(result) assert.is_table(result.content) assert.equal("FILE_SAVED", result.content[1].text) assert.equal("text", result.content[1].type) assert.is_string(result.content[2].text) assert.equal("text", result.content[2].type) end) it("should create MCP-compliant response on diff rejection", function() local result = nil local co = coroutine.create(function() result = diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, test_tab_name) end) -- Start the coroutine local success, err = coroutine.resume(co) assert.is_true(success, "Coroutine should start successfully: " .. tostring(err)) assert.equal("suspended", coroutine.status(co), "Coroutine should be suspended waiting for user action") -- Simulate diff rejection vim.schedule(function() diff._resolve_diff_as_rejected(test_tab_name) end) -- Wait for resolution vim.wait(100, function() -- Reduced from 1000ms to 100ms return coroutine.status(co) == "dead" end) assert.is_not_nil(result) assert.is_table(result.content) assert.equal("DIFF_REJECTED", result.content[1].text) assert.equal("text", result.content[1].type) assert.equal(test_tab_name, result.content[2].text) assert.equal("text", result.content[2].type) end) it("should handle non-existent old file as new file", function() local non_existent_file = "/tmp/non_existent_file.txt" -- Set up mock resolution _G.claude_deferred_responses = { [tostring(coroutine.running())] = function() -- Mock resolution end, } local co = coroutine.create(function() diff.open_diff_blocking(non_existent_file, test_new_file, test_content_new, test_tab_name) end) local success = coroutine.resume(co) assert.is_true(success, "Should handle new file scenario successfully") -- The coroutine should yield (waiting for user action) assert.equal("suspended", coroutine.status(co)) -- Verify diff state was created for new file local active_diffs = diff._get_active_diffs() assert.is_table(active_diffs[test_tab_name]) assert.is_true(active_diffs[test_tab_name].is_new_file) end) it("should replace existing diff with same tab_name", function() -- First diff local co1 = coroutine.create(function() diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, test_tab_name) end) local success1, err1 = coroutine.resume(co1) assert.is_true(success1, "First diff should start successfully: " .. tostring(err1)) assert.equal("suspended", coroutine.status(co1), "First coroutine should be suspended") -- Second diff with same tab_name should replace the first local co2 = coroutine.create(function() diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, test_tab_name) end) local success2, err2 = coroutine.resume(co2) assert.is_true(success2, "Second diff should start successfully: " .. tostring(err2)) assert.equal("suspended", coroutine.status(co2), "Second coroutine should be suspended") -- Clean up both coroutines vim.schedule(function() diff._resolve_diff_as_rejected(test_tab_name) end) vim.wait(100, function() -- Reduced from 1000ms to 100ms return coroutine.status(co2) == "dead" end) end) end) describe("Resource cleanup", function() it("should clean up buffers on completion", function() local initial_buffers = vim.api.nvim_list_bufs() local co = coroutine.create(function() diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, test_tab_name) end) coroutine.resume(co) -- Simulate completion vim.schedule(function() diff._resolve_diff_as_saved(test_tab_name, 1) end) vim.wait(1000, function() return coroutine.status(co) == "dead" end) -- Check that no extra buffers remain local final_buffers = vim.api.nvim_list_bufs() -- Allow for some variance due to test environment assert.is_true(#final_buffers <= #initial_buffers + 2, "Should not leak buffers") end) it("should clean up autocmds on completion", function() local initial_autocmd_count = #vim.api.nvim_get_autocmds({ group = "ClaudeCodeMCPDiff" }) local co = coroutine.create(function() diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, test_tab_name) end) coroutine.resume(co) -- Verify autocmds were created local mid_autocmd_count = #vim.api.nvim_get_autocmds({ group = "ClaudeCodeMCPDiff" }) assert.is_true(mid_autocmd_count > initial_autocmd_count, "Autocmds should be created") -- Simulate completion vim.schedule(function() diff._resolve_diff_as_rejected(test_tab_name) end) vim.wait(1000, function() return coroutine.status(co) == "dead" end) -- Check that autocmds were cleaned up local final_autocmd_count = #vim.api.nvim_get_autocmds({ group = "ClaudeCodeMCPDiff" }) assert.equal(initial_autocmd_count, final_autocmd_count, "Autocmds should be cleaned up") end) end) describe("State management", function() it("should track active diffs correctly", function() local co = coroutine.create(function() diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, test_tab_name) end) coroutine.resume(co) -- Verify diff is tracked -- Note: This test may need adjustment based on actual buffer creation -- Clean up vim.schedule(function() diff._resolve_diff_as_rejected(test_tab_name) end) vim.wait(1000, function() return coroutine.status(co) == "dead" end) end) it("should handle concurrent diffs with different tab_names", function() local tab_name_1 = "test_diff_1" local tab_name_2 = "test_diff_2" local co1 = coroutine.create(function() diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, tab_name_1) end) local co2 = coroutine.create(function() diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, tab_name_2) end) coroutine.resume(co1) coroutine.resume(co2) assert.equal("suspended", coroutine.status(co1), "First diff should be suspended") assert.equal("suspended", coroutine.status(co2), "Second diff should be suspended") -- Resolve both vim.schedule(function() diff._resolve_diff_as_saved(tab_name_1, 1) diff._resolve_diff_as_rejected(tab_name_2) end) vim.wait(100, function() -- Reduced from 1000ms to 100ms return coroutine.status(co1) == "dead" and coroutine.status(co2) == "dead" end) end) end) describe("Error handling", function() it("should handle buffer creation failures gracefully", function() -- Mock vim.api.nvim_create_buf to fail local original_create_buf = vim.api.nvim_create_buf vim.api.nvim_create_buf = function() return 0 end local co = coroutine.create(function() diff.open_diff_blocking(test_old_file, test_new_file, test_content_new, test_tab_name) end) local success, err = coroutine.resume(co) assert.is_false(success, "Should fail with buffer creation error") assert.is_table(err) assert.equal(-32000, err.code) assert_contains(err.message, "Diff setup failed") -- Restore original function vim.api.nvim_create_buf = original_create_buf end) end) end) ================================================ FILE: tests/unit/diff_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("Diff Module", function() local diff local original_vim_functions = {} local function setup() package.loaded["claudecode.diff"] = nil package.loaded["claudecode.config"] = nil assert(_G.vim, "Global vim mock not initialized by busted_setup.lua") assert(_G.vim.fn, "Global vim.fn mock not initialized") -- For this spec, the global mock (which now includes stdpath) should be largely sufficient. -- The local mock_vim that was missing stdpath is removed. diff = require("claudecode.diff") end local function teardown() for path, func in pairs(original_vim_functions) do local parts = {} for part in string.gmatch(path, "[^%.]+") do table.insert(parts, part) end local obj = _G.vim for i = 1, #parts - 1 do obj = obj[parts[i]] end obj[parts[#parts]] = func end original_vim_functions = {} if original_vim_functions["cmd"] then _G.vim.cmd = original_vim_functions["cmd"] original_vim_functions["cmd"] = nil end -- _G.vim itself is managed by busted_setup.lua end before_each(function() setup() end) after_each(function() teardown() end) describe("Configuration", function() it("should store configuration in setup", function() local test_config = { diff_opts = { keep_terminal_focus = true, }, } diff.setup(test_config) -- We can't directly test the stored config since it's local to the module, -- but we can test that setup doesn't error and the module is properly initialized expect(type(diff.setup)).to_be("function") expect(type(diff.open_diff)).to_be("function") end) it("should handle empty configuration", function() -- This should not error diff.setup(nil) diff.setup({}) expect(type(diff.setup)).to_be("function") end) end) describe("Temporary File Management (via Native Diff)", function() it("should create temporary files with correct content through native diff", function() local test_content = "This is test content\nLine 2\nLine 3" local old_file_path = "/path/to/old.lua" local new_file_path = "/path/to/new.lua" local mock_file = { write = function() end, close = function() end, } local old_io_open = io.open rawset(io, "open", function() return mock_file end) local result = diff._open_native_diff(old_file_path, new_file_path, test_content, "Test Diff") expect(result).to_be_table() expect(result.success).to_be_true() expect(result.temp_file).to_be_string() expect(result.temp_file:find("claudecode_diff", 1, true)).not_to_be_nil() local expected_suffix = vim.fn.fnamemodify(new_file_path, ":t") .. ".new" expect(result.temp_file:find(expected_suffix, 1, true)).not_to_be_nil() rawset(io, "open", old_io_open) end) it("should handle file creation errors in native diff", function() local test_content = "test" local old_file_path = "/path/to/old.lua" local new_file_path = "/path/to/new.lua" local old_io_open = io.open rawset(io, "open", function() return nil end) local result = diff._open_native_diff(old_file_path, new_file_path, test_content, "Test Diff") expect(result).to_be_table() expect(result.success).to_be_false() expect(result.error).to_be_string() expect(result.error:find("Failed to create temporary file", 1, true)).not_to_be_nil() expect(result.temp_file).to_be_nil() -- Ensure no temp_file is created on failure rawset(io, "open", old_io_open) end) end) describe("Native Diff Implementation", function() it("should create diff with correct parameters", function() diff.setup({ diff_opts = { vertical_split = true, show_diff_stats = false, auto_close_on_accept = true, }, }) local commands = {} if _G.vim and rawget(original_vim_functions, "cmd") == nil then original_vim_functions["cmd"] = _G.vim.cmd end _G.vim.cmd = function(cmd) table.insert(commands, cmd) end local mock_file = { write = function() end, close = function() end, } local old_io_open = io.open rawset(io, "open", function() return mock_file end) local result = diff._open_native_diff("/path/to/old.lua", "/path/to/new.lua", "new content here", "Test Diff") expect(result.success).to_be_true() expect(result.provider).to_be("native") expect(result.tab_name).to_be("Test Diff") local found_vsplit = false local found_diffthis = false local found_edit = false for _, cmd in ipairs(commands) do if cmd:find("vsplit", 1, true) then found_vsplit = true end if cmd:find("diffthis", 1, true) then found_diffthis = true end if cmd:find("edit", 1, true) then found_edit = true end end expect(found_vsplit).to_be_true() expect(found_diffthis).to_be_true() expect(found_edit).to_be_true() rawset(io, "open", old_io_open) end) it("should use horizontal split when configured", function() diff.setup({ diff_opts = { vertical_split = false, show_diff_stats = false, auto_close_on_accept = true, }, }) local commands = {} if _G.vim and rawget(original_vim_functions, "cmd") == nil then original_vim_functions["cmd"] = _G.vim.cmd end _G.vim.cmd = function(cmd) table.insert(commands, cmd) end local mock_file = { write = function() end, close = function() end, } local old_io_open = io.open rawset(io, "open", function() return mock_file end) local result = diff._open_native_diff("/path/to/old.lua", "/path/to/new.lua", "new content here", "Test Diff") expect(result.success).to_be_true() local found_split = false local found_vertical_split = false for _, cmd in ipairs(commands) do if cmd:find("split", 1, true) and not cmd:find("vertical split", 1, true) then found_split = true end if cmd:find("vertical split", 1, true) then found_vertical_split = true end end expect(found_split).to_be_true() expect(found_vertical_split).to_be_false() rawset(io, "open", old_io_open) end) it("should handle temporary file creation errors", function() diff.setup({}) local old_io_open = io.open rawset(io, "open", function() return nil end) local result = diff._open_native_diff("/path/to/old.lua", "/path/to/new.lua", "new content here", "Test Diff") expect(result.success).to_be_false() expect(result.error).to_be_string() expect(result.error:find("Failed to create temporary file", 1, true)).not_to_be_nil() rawset(io, "open", old_io_open) end) end) describe("Filetype Propagation", function() it("should propagate original filetype to proposed buffer", function() diff.setup({}) -- Spy on nvim_set_option_value spy.on(_G.vim.api, "nvim_set_option_value") local mock_file = { write = function() end, close = function() end, } local old_io_open = io.open rawset(io, "open", function() return mock_file end) local result = diff._open_native_diff("/tmp/test.ts", "/tmp/test.ts", "-- new", "Propagate FT") expect(result.success).to_be_true() -- Verify spy called with filetype typescript local calls = _G.vim.api.nvim_set_option_value.calls or {} local found = false for _, c in ipairs(calls) do if c.vals[1] == "filetype" and c.vals[2] == "typescript" then found = true break end end expect(found).to_be_true() rawset(io, "open", old_io_open) end) end) describe("Open Diff Function", function() it("should use native provider", function() diff.setup({}) local native_called = false diff._open_native_diff = function(old_path, new_path, content, tab_name) native_called = true return { success = true, provider = "native", tab_name = tab_name, temp_file = "/mock/temp/file.new", } end local result = diff.open_diff("/path/to/old.lua", "/path/to/new.lua", "new content", "Test Diff") expect(native_called).to_be_true() expect(result.provider).to_be("native") expect(result.success).to_be_true() end) end) describe("Dirty Buffer Detection", function() it("should detect clean buffer", function() -- Mock vim.fn.bufnr to return a valid buffer number local old_bufnr = _G.vim.fn.bufnr _G.vim.fn.bufnr = function(path) if path == "/path/to/clean.lua" then return 1 end return -1 end -- Mock vim.api.nvim_buf_get_option to return not modified local old_get_option = _G.vim.api.nvim_buf_get_option _G.vim.api.nvim_buf_get_option = function(bufnr, option) if bufnr == 1 and option == "modified" then return false end return nil end -- Test the is_buffer_dirty function indirectly through _setup_blocking_diff local clean_params = { tab_name = "test_clean", old_file_path = "/path/to/clean.lua", new_file_path = "/path/to/clean.lua", content = "test content", } -- Mock file operations _G.vim.fn.filereadable = function() return 1 end _G.vim.api.nvim_list_bufs = function() return {} end _G.vim.api.nvim_list_wins = function() return {} end _G.vim.api.nvim_create_buf = function() return 1 end _G.vim.api.nvim_buf_set_name = function() end _G.vim.api.nvim_buf_set_lines = function() end _G.vim.api.nvim_set_option_value = function() end _G.vim.cmd = function() end local old_io_open = io.open rawset(io, "open", function() return { write = function() return true end, close = function() return true end, } end) -- This should not throw an error for clean buffer local success, err = pcall(function() diff._setup_blocking_diff(clean_params, function() end) end) -- The test might still fail due to incomplete mocking, so let's just check that -- it's not failing due to dirty buffer (the error should not mention dirty buffer) if not success then expect(err.data:find("unsaved changes")).to_be_nil() else expect(success).to_be_true() end -- Restore mocks _G.vim.fn.bufnr = old_bufnr _G.vim.api.nvim_buf_get_option = old_get_option rawset(io, "open", old_io_open) end) it("should detect dirty buffer and throw error", function() -- Mock vim.fn.bufnr to return a valid buffer number local old_bufnr = _G.vim.fn.bufnr _G.vim.fn.bufnr = function(path) if path == "/path/to/dirty.lua" then return 2 end return -1 end -- Mock vim.api.nvim_buf_get_option to return modified local old_get_option = _G.vim.api.nvim_buf_get_option _G.vim.api.nvim_buf_get_option = function(bufnr, option) if bufnr == 2 and option == "modified" then return true -- Buffer is dirty end return nil end local dirty_params = { tab_name = "test_dirty", old_file_path = "/path/to/dirty.lua", new_file_path = "/path/to/dirty.lua", content = "test content", } -- Mock file operations _G.vim.fn.filereadable = function() return 1 end -- This should throw an error for dirty buffer local success, err = pcall(function() diff._setup_blocking_diff(dirty_params, function() end) end) expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32000) expect(err.message).to_be("Diff setup failed") expect(err.data).to_be_string() -- For now, let's just verify the basic error structure -- The important thing is that it fails when buffer is dirty, not the exact message expect(#err.data > 0).to_be_true() -- Restore mocks _G.vim.fn.bufnr = old_bufnr _G.vim.api.nvim_buf_get_option = old_get_option end) it("should handle non-existent buffer", function() -- Mock vim.fn.bufnr to return -1 (buffer not found) local old_bufnr = _G.vim.fn.bufnr _G.vim.fn.bufnr = function() return -1 end local nonexistent_params = { tab_name = "test_nonexistent", old_file_path = "/path/to/nonexistent.lua", new_file_path = "/path/to/nonexistent.lua", content = "test content", } -- Mock file operations _G.vim.fn.filereadable = function() return 1 end _G.vim.api.nvim_list_bufs = function() return {} end _G.vim.api.nvim_list_wins = function() return {} end _G.vim.api.nvim_create_buf = function() return 1 end _G.vim.api.nvim_buf_set_name = function() end _G.vim.api.nvim_buf_set_lines = function() end _G.vim.api.nvim_set_option_value = function() end _G.vim.cmd = function() end local old_io_open = io.open rawset(io, "open", function() return { write = function() return true end, close = function() return true end, } end) -- This should not throw an error for non-existent buffer local success, err = pcall(function() diff._setup_blocking_diff(nonexistent_params, function() end) end) -- Check that it's not failing due to dirty buffer if not success then expect(err.data:find("unsaved changes")).to_be_nil() else expect(success).to_be_true() end -- Restore mocks _G.vim.fn.bufnr = old_bufnr rawset(io, "open", old_io_open) end) it("should skip dirty check for new files", function() local new_file_params = { tab_name = "test_new_file", old_file_path = "/path/to/newfile.lua", new_file_path = "/path/to/newfile.lua", content = "test content", } -- Mock file operations - file doesn't exist _G.vim.fn.filereadable = function() return 0 end -- File doesn't exist _G.vim.api.nvim_list_bufs = function() return {} end _G.vim.api.nvim_list_wins = function() return {} end _G.vim.api.nvim_create_buf = function() return 1 end _G.vim.api.nvim_buf_set_name = function() end _G.vim.api.nvim_buf_set_lines = function() end _G.vim.api.nvim_set_option_value = function() end _G.vim.cmd = function() end local old_io_open = io.open rawset(io, "open", function() return { write = function() return true end, close = function() return true end, } end) -- This should not throw an error for new files (no dirty check needed) local success, err = pcall(function() diff._setup_blocking_diff(new_file_params, function() end) end) -- Check that it's not failing due to dirty buffer if not success then expect(err.data:find("unsaved changes")).to_be_nil() else expect(success).to_be_true() end rawset(io, "open", old_io_open) end) end) teardown() end) ================================================ FILE: tests/unit/directory_at_mention_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("Directory At Mention Functionality", function() local integrations local visual_commands local mock_vim local function setup_mocks() package.loaded["claudecode.integrations"] = nil package.loaded["claudecode.visual_commands"] = nil package.loaded["claudecode.logger"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function() end, error = function() end, } mock_vim = { fn = { isdirectory = function(path) if string.match(path, "/lua$") or string.match(path, "/tests$") or string.match(path, "src") then return 1 end return 0 end, getcwd = function() return "/Users/test/project" end, mode = function() return "n" end, }, api = { nvim_get_current_win = function() return 1002 end, nvim_get_mode = function() return { mode = "n" } end, }, bo = { filetype = "neo-tree" }, } _G.vim = mock_vim end before_each(function() setup_mocks() end) describe("directory handling in integrations", function() before_each(function() integrations = require("claudecode.integrations") end) it("should return directory paths from neo-tree", function() local mock_state = { tree = { get_node = function() return { type = "directory", path = "/Users/test/project/lua", } end, }, } package.loaded["neo-tree.sources.manager"] = { get_state = function() return mock_state end, } local files, err = integrations._get_neotree_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/lua") end) it("should return directory paths from nvim-tree", function() package.loaded["nvim-tree.api"] = { tree = { get_node_under_cursor = function() return { type = "directory", absolute_path = "/Users/test/project/tests", } end, }, marks = { list = function() return {} end, }, } mock_vim.bo.filetype = "NvimTree" local files, err = integrations._get_nvim_tree_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/tests") end) end) describe("visual commands directory handling", function() before_each(function() visual_commands = require("claudecode.visual_commands") end) it("should include directories in visual selections", function() local visual_data = { tree_state = { tree = { get_node = function(self, line) if line == 1 then return { type = "file", path = "/Users/test/project/init.lua", get_depth = function() return 2 end, } elseif line == 2 then return { type = "directory", path = "/Users/test/project/lua", get_depth = function() return 2 end, } end return nil end, }, }, tree_type = "neo-tree", start_pos = 1, end_pos = 2, } local files, err = visual_commands.get_files_from_visual_selection(visual_data) expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(2) expect(files[1]).to_be("/Users/test/project/init.lua") expect(files[2]).to_be("/Users/test/project/lua") end) it("should respect depth protection for directories", function() local visual_data = { tree_state = { tree = { get_node = function(line) if line == 1 then return { type = "directory", path = "/Users/test/project", get_depth = function() return 1 end, } end return nil end, }, }, tree_type = "neo-tree", start_pos = 1, end_pos = 1, } local files, err = visual_commands.get_files_from_visual_selection(visual_data) expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(0) -- Root-level directory should be skipped end) end) end) ================================================ FILE: tests/unit/init_spec.lua ================================================ require("tests.busted_setup") require("tests.mocks.vim") describe("claudecode.init", function() ---@class AutocmdOptions ---@field group string|number|nil ---@field pattern string|string[]|nil ---@field buffer number|nil ---@field desc string|nil ---@field callback function|nil ---@field once boolean|nil ---@field nested boolean|nil local saved_vim_api = vim.api local saved_vim_deepcopy = vim.deepcopy local saved_vim_tbl_deep_extend = vim.tbl_deep_extend local saved_vim_notify = vim.notify local saved_vim_fn = vim.fn local saved_vim_log = vim.log local saved_require = _G.require local mock_server = { start = function() return true, 12345 end, ---@type SpyableFunction stop = function() return true end, } local mock_lockfile = { create = function() return true, "/mock/path", "mock-auth-token-12345" end, ---@type SpyableFunction remove = function() return true end, generate_auth_token = function() return "mock-auth-token-12345" end, } local mock_selection = { enable = function() end, disable = function() end, } local SpyObject = {} function SpyObject.new(fn) local spy_obj = { _original = fn, calls = {}, } function spy_obj.spy() return { was_called = function(n) assert(#spy_obj.calls == n, "Expected " .. n .. " calls, got " .. #spy_obj.calls) return true end, was_not_called = function() assert(#spy_obj.calls == 0, "Expected 0 calls, got " .. #spy_obj.calls) return true end, was_called_with = function(...) -- args is unused but keeping the parameter for clarity, as the function signature might be relevant for future tests assert(#spy_obj.calls > 0, "Function was never called") return true end, } end return setmetatable(spy_obj, { __call = function(self, ...) table.insert(self.calls, { vals = { ... } }) if self._original then return self._original(...) end end, }) end local match = { is_table = function() return { is_table = true } end, } before_each(function() vim.api = { nvim_create_autocmd = SpyObject.new(function() end), nvim_create_augroup = SpyObject.new(function() return 1 end), nvim_create_user_command = SpyObject.new(function() end), nvim_echo = SpyObject.new(function() end), } vim.deepcopy = function(t) return t end vim.tbl_deep_extend = function(_, default, override) local result = {} for k, v in pairs(default) do result[k] = v end for k, v in pairs(override) do result[k] = v end return result end vim.notify = spy.new(function() end) vim.fn = { ---@type vim_fn_table getpid = function() return 123 end, expand = function() return "/mock/path" end, mode = function() return "n" end, delete = function(_, _) return 0 end, filereadable = function(_) return 1 end, fnamemodify = function(fname, _) return fname end, getcwd = function() return "/mock/cwd" end, mkdir = function(_, _, _) return 1 end, buflisted = function(_) return 1 end, bufname = function(_) return "mockbuffer" end, bufnr = function(_) return 1 end, win_getid = function() return 1 end, win_gotoid = function(_) return true end, line = function(_) return 1 end, col = function(_) return 1 end, virtcol = function(_) return 1 end, getpos = function(_) return { 0, 1, 1, 0 } end, setpos = function(_, _) return true end, tempname = function() return "/tmp/mocktemp" end, globpath = function(_, _) return "" end, stdpath = function(_) return "/mock/stdpath" end, json_encode = function(_) return "{}" end, json_decode = function(_) return {} end, termopen = function(_, _) return 0 end, } vim.log = { levels = { NONE = 0, INFO = 2, WARN = 3, ERROR = 4, DEBUG = 5, TRACE = 6, }, } mock_server.stop = SpyObject.new(mock_server.stop) mock_lockfile.remove = SpyObject.new(mock_lockfile.remove) _G.require = function(mod) if mod == "claudecode.server.init" then return mock_server elseif mod == "claudecode.lockfile" then return mock_lockfile elseif mod == "claudecode.selection" then return mock_selection else return saved_require(mod) end end _G.match = match end) after_each(function() vim.api = saved_vim_api vim.deepcopy = saved_vim_deepcopy vim.tbl_deep_extend = saved_vim_tbl_deep_extend vim.notify = saved_vim_notify vim.fn = saved_vim_fn vim.log = saved_vim_log _G.require = saved_require end) describe("setup", function() it("should register VimLeavePre autocmd for auto-shutdown", function() local claudecode = require("claudecode") claudecode.setup() assert(#vim.api.nvim_create_augroup.calls > 0, "nvim_create_augroup was not called") assert(#vim.api.nvim_create_autocmd.calls > 0, "nvim_create_autocmd was not called") assert(vim.api.nvim_create_autocmd.calls[1].vals[1] == "VimLeavePre", "Expected VimLeavePre event") end) end) describe("auto-shutdown", function() it("should stop the server and remove lockfile when Neovim exits", function() local claudecode = require("claudecode") claudecode.setup() claudecode.start() local callback_fn = nil for _, call in ipairs(vim.api.nvim_create_autocmd.calls) do if call.vals[1] == "VimLeavePre" then -- The mock for nvim_create_augroup returns 1, and this is passed as the group. if call.vals[2] and call.vals[2].group == 1 then callback_fn = call.vals[2].callback break end end end assert(callback_fn, "Callback for VimLeavePre with ClaudeCodeShutdown group not found") mock_server.stop.calls = {} mock_lockfile.remove.calls = {} if callback_fn then callback_fn() end assert(#mock_server.stop.calls > 0, "Server stop was not called") assert(#mock_lockfile.remove.calls > 0, "Lockfile remove was not called") end) it("should do nothing if the server is not running", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) local opts = vim.api.nvim_create_autocmd.calls[1].vals[2] local callback_fn = opts.callback mock_server.stop.calls = {} mock_lockfile.remove.calls = {} if callback_fn then callback_fn() end assert(#mock_server.stop.calls == 0, "Server stop was called unexpectedly") assert(#mock_lockfile.remove.calls == 0, "Lockfile remove was called unexpectedly") end) end) describe("ClaudeCode command with arguments", function() local mock_terminal before_each(function() mock_terminal = { toggle = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), setup = spy.new(function() end), ensure_visible = spy.new(function() end), } local original_require = _G.require _G.require = function(mod) if mod == "claudecode.terminal" then return mock_terminal elseif mod == "claudecode.server.init" then return mock_server elseif mod == "claudecode.lockfile" then return mock_lockfile elseif mod == "claudecode.selection" then return mock_selection else return original_require(mod) end end end) it("should register ClaudeCode command with nargs='*'", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) local command_found = false for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_found = true local config = call.vals[3] assert.is_equal("*", config.nargs) assert.is_true( string.find(config.desc, "optional arguments") ~= nil, "Description should mention optional arguments" ) break end end assert.is_true(command_found, "ClaudeCode command was not registered") end) it("should register ClaudeCodeOpen command with nargs='*'", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) local command_found = false for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeOpen" then command_found = true local config = call.vals[3] assert.is_equal("*", config.nargs) assert.is_true( string.find(config.desc, "optional arguments") ~= nil, "Description should mention optional arguments" ) break end end assert.is_true(command_found, "ClaudeCodeOpen command was not registered") end) it("should parse and pass arguments to terminal.toggle for ClaudeCode command", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) -- Find and call the ClaudeCode command handler local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end assert.is_function(command_handler, "Command handler should be a function") command_handler({ args = "--resume --verbose" }) assert(#mock_terminal.simple_toggle.calls > 0, "terminal.simple_toggle was not called") local call_args = mock_terminal.simple_toggle.calls[1].vals assert.is_table(call_args[1], "First argument should be a table") assert.is_equal("--resume --verbose", call_args[2], "Second argument should be the command args") end) it("should parse and pass arguments to terminal.open for ClaudeCodeOpen command", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) -- Find and call the ClaudeCodeOpen command handler local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeOpen" then command_handler = call.vals[2] break end end assert.is_function(command_handler, "Command handler should be a function") command_handler({ args = "--flag1 --flag2" }) assert(#mock_terminal.open.calls > 0, "terminal.open was not called") local call_args = mock_terminal.open.calls[1].vals assert.is_table(call_args[1], "First argument should be a table") assert.is_equal("--flag1 --flag2", call_args[2], "Second argument should be the command args") end) it("should handle empty arguments gracefully", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end command_handler({ args = "" }) assert(#mock_terminal.simple_toggle.calls > 0, "terminal.simple_toggle was not called") local call_args = mock_terminal.simple_toggle.calls[1].vals assert.is_nil(call_args[2], "Second argument should be nil for empty args") end) it("should handle nil arguments gracefully", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end command_handler({ args = nil }) assert(#mock_terminal.simple_toggle.calls > 0, "terminal.simple_toggle was not called") local call_args = mock_terminal.simple_toggle.calls[1].vals assert.is_nil(call_args[2], "Second argument should be nil when args is nil") end) it("should maintain backward compatibility when no arguments provided", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCode" then command_handler = call.vals[2] break end end command_handler({}) assert(#mock_terminal.simple_toggle.calls > 0, "terminal.simple_toggle was not called") local call_args = mock_terminal.simple_toggle.calls[1].vals assert.is_nil(call_args[2], "Second argument should be nil when no args provided") end) end) describe("ClaudeCodeSelectModel command with arguments", function() local mock_terminal local mock_ui_select local mock_vim_cmd before_each(function() mock_terminal = { toggle = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), } -- Mock vim.ui.select to automatically select the first model mock_ui_select = spy.new(function(models, opts, callback) -- Simulate user selecting the first model callback(models[1]) end) -- Mock vim.cmd to capture command execution mock_vim_cmd = spy.new(function(cmd) end) vim.ui = vim.ui or {} vim.ui.select = mock_ui_select vim.cmd = mock_vim_cmd local original_require = _G.require _G.require = function(mod) if mod == "claudecode.terminal" then return mock_terminal elseif mod == "claudecode.server.init" then return mock_server elseif mod == "claudecode.lockfile" then return mock_lockfile elseif mod == "claudecode.selection" then return mock_selection else return original_require(mod) end end end) it("should register ClaudeCodeSelectModel command with correct configuration", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) local command_found = false for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeSelectModel" then command_found = true local config = call.vals[3] assert.is_equal("*", config.nargs) assert.is_true( string.find(config.desc, "model.*arguments") ~= nil, "Description should mention model and arguments" ) break end end assert.is_true(command_found, "ClaudeCodeSelectModel command was not registered") end) it("should call ClaudeCode command with model arg when no additional args provided", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) -- Find and call the ClaudeCodeSelectModel command handler local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeSelectModel" then command_handler = call.vals[2] break end end assert.is_function(command_handler, "Command handler should be a function") command_handler({ args = "" }) -- Verify vim.ui.select was called assert(#mock_ui_select.calls > 0, "vim.ui.select was not called") -- Verify vim.cmd was called with the correct ClaudeCode command assert(#mock_vim_cmd.calls > 0, "vim.cmd was not called") local cmd_arg = mock_vim_cmd.calls[1].vals[1] assert.is_equal("ClaudeCode --model opus", cmd_arg, "Should call ClaudeCode with model arg") end) it("should call ClaudeCode command with model and additional args", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) -- Find and call the ClaudeCodeSelectModel command handler local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeSelectModel" then command_handler = call.vals[2] break end end assert.is_function(command_handler, "Command handler should be a function") command_handler({ args = "--resume --verbose" }) -- Verify vim.ui.select was called assert(#mock_ui_select.calls > 0, "vim.ui.select was not called") -- Verify vim.cmd was called with the correct ClaudeCode command including additional args assert(#mock_vim_cmd.calls > 0, "vim.cmd was not called") local cmd_arg = mock_vim_cmd.calls[1].vals[1] assert.is_equal( "ClaudeCode --model opus --resume --verbose", cmd_arg, "Should call ClaudeCode with model and additional args" ) end) it("should handle user cancellation gracefully", function() local claudecode = require("claudecode") claudecode.setup({ auto_start = false }) -- Mock vim.ui.select to simulate user cancellation vim.ui.select = spy.new(function(models, opts, callback) callback(nil) -- User cancelled end) -- Find and call the ClaudeCodeSelectModel command handler local command_handler for _, call in ipairs(vim.api.nvim_create_user_command.calls) do if call.vals[1] == "ClaudeCodeSelectModel" then command_handler = call.vals[2] break end end assert.is_function(command_handler, "Command handler should be a function") command_handler({ args = "--resume" }) -- Verify vim.ui.select was called assert(#vim.ui.select.calls > 0, "vim.ui.select was not called") -- Verify vim.cmd was NOT called due to user cancellation assert.is_equal(0, #mock_vim_cmd.calls, "vim.cmd should not be called when user cancels") end) end) end) ================================================ FILE: tests/unit/logger_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("Logger", function() local logger local original_vim_schedule local original_vim_notify local original_nvim_echo local scheduled_calls = {} local notify_calls = {} local echo_calls = {} local function setup() package.loaded["claudecode.logger"] = nil -- Mock vim.schedule to track calls original_vim_schedule = vim.schedule vim.schedule = function(fn) table.insert(scheduled_calls, fn) -- Immediately execute the function for testing fn() end -- Mock vim.notify to track calls original_vim_notify = vim.notify vim.notify = function(msg, level, opts) table.insert(notify_calls, { msg = msg, level = level, opts = opts }) end -- Mock nvim_echo to track calls original_nvim_echo = vim.api.nvim_echo vim.api.nvim_echo = function(chunks, history, opts) table.insert(echo_calls, { chunks = chunks, history = history, opts = opts }) end logger = require("claudecode.logger") -- Set log level to TRACE to enable all logging levels for testing logger.setup({ log_level = "trace" }) end local function teardown() vim.schedule = original_vim_schedule vim.notify = original_vim_notify vim.api.nvim_echo = original_nvim_echo scheduled_calls = {} notify_calls = {} echo_calls = {} end before_each(function() setup() end) after_each(function() teardown() end) describe("error logging", function() it("should wrap error calls in vim.schedule", function() logger.error("test", "error message") -- Should have made one scheduled call expect(#scheduled_calls).to_be(1) -- Should have called vim.notify with error level expect(#notify_calls).to_be(1) expect(notify_calls[1].level).to_be(vim.log.levels.ERROR) assert_contains(notify_calls[1].msg, "error message") end) it("should handle error calls without component", function() logger.error("error message") expect(#scheduled_calls).to_be(1) expect(#notify_calls).to_be(1) assert_contains(notify_calls[1].msg, "error message") end) end) describe("warn logging", function() it("should wrap warn calls in vim.schedule", function() logger.warn("test", "warning message") -- Should have made one scheduled call expect(#scheduled_calls).to_be(1) -- Should have called vim.notify with warn level expect(#notify_calls).to_be(1) expect(notify_calls[1].level).to_be(vim.log.levels.WARN) assert_contains(notify_calls[1].msg, "warning message") end) it("should handle warn calls without component", function() logger.warn("warning message") expect(#scheduled_calls).to_be(1) expect(#notify_calls).to_be(1) assert_contains(notify_calls[1].msg, "warning message") end) end) describe("info logging", function() it("should wrap info calls in vim.schedule", function() logger.info("test", "info message") -- Should have made one scheduled call expect(#scheduled_calls).to_be(1) -- Should have called nvim_echo instead of notify expect(#echo_calls).to_be(1) expect(#notify_calls).to_be(0) assert_contains(echo_calls[1].chunks[1][1], "info message") end) end) describe("debug logging", function() it("should wrap debug calls in vim.schedule", function() logger.debug("test", "debug message") -- Should have made one scheduled call expect(#scheduled_calls).to_be(1) -- Should have called nvim_echo instead of notify expect(#echo_calls).to_be(1) expect(#notify_calls).to_be(0) assert_contains(echo_calls[1].chunks[1][1], "debug message") end) end) describe("trace logging", function() it("should wrap trace calls in vim.schedule", function() logger.trace("test", "trace message") -- Should have made one scheduled call expect(#scheduled_calls).to_be(1) -- Should have called nvim_echo instead of notify expect(#echo_calls).to_be(1) expect(#notify_calls).to_be(0) assert_contains(echo_calls[1].chunks[1][1], "trace message") end) end) describe("fast event context safety", function() it("should not call vim API functions directly", function() -- Simulate a fast event context by removing the mocked functions -- and ensuring no direct calls are made local direct_notify_called = false local direct_echo_called = false vim.notify = function() direct_notify_called = true end vim.api.nvim_echo = function() direct_echo_called = true end vim.schedule = function(fn) -- Don't execute the function, just verify it was scheduled table.insert(scheduled_calls, fn) end logger.error("test", "error in fast context") logger.warn("test", "warn in fast context") logger.info("test", "info in fast context") -- All should be scheduled, none should be called directly expect(#scheduled_calls).to_be(3) expect(direct_notify_called).to_be_false() expect(direct_echo_called).to_be_false() end) end) end) ================================================ FILE: tests/unit/mini_files_integration_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("mini.files integration", function() local integrations local mock_vim local function setup_mocks() package.loaded["claudecode.integrations"] = nil package.loaded["claudecode.logger"] = nil package.loaded["claudecode.visual_commands"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function() end, error = function() end, } -- Mock visual_commands package.loaded["claudecode.visual_commands"] = { get_visual_range = function() return 1, 3 -- Return lines 1-3 by default end, } mock_vim = { fn = { mode = function() return "n" -- Normal mode by default end, filereadable = function(path) if path:match("%.lua$") or path:match("%.txt$") then return 1 end return 0 end, isdirectory = function(path) if path:match("/$") or path:match("/src$") then return 1 end return 0 end, }, api = { nvim_get_current_buf = function() return 1 -- Mock buffer ID end, }, bo = { filetype = "minifiles" }, } _G.vim = mock_vim end before_each(function() setup_mocks() integrations = require("claudecode.integrations") end) describe("_get_mini_files_selection", function() it("should get single file under cursor", function() -- Mock mini.files module local mock_mini_files = { get_fs_entry = function(buf_id) -- Verify buffer ID is passed correctly if buf_id ~= 1 then return nil end return { path = "/Users/test/project/main.lua" } end, } package.loaded["mini.files"] = mock_mini_files local files, err = integrations._get_mini_files_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/main.lua") end) it("should get directory under cursor", function() -- Mock mini.files module local mock_mini_files = { get_fs_entry = function(buf_id) -- Verify buffer ID is passed correctly if buf_id ~= 1 then return nil end return { path = "/Users/test/project/src" } end, } package.loaded["mini.files"] = mock_mini_files local files, err = integrations._get_mini_files_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/src") end) it("should handle mini.files buffer path format", function() -- Mock mini.files module that returns buffer-style paths local mock_mini_files = { get_fs_entry = function(buf_id) if buf_id ~= 1 then return nil end return { path = "minifiles://42//Users/test/project/buffer_file.lua" } end, } package.loaded["mini.files"] = mock_mini_files local files, err = integrations._get_mini_files_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/buffer_file.lua") end) it("should handle various mini.files buffer path formats", function() -- Test different buffer path formats that could occur local test_cases = { { input = "minifiles://42/Users/test/file.lua", expected = "Users/test/file.lua" }, { input = "minifiles://42//Users/test/file.lua", expected = "/Users/test/file.lua" }, { input = "minifiles://123///Users/test/file.lua", expected = "//Users/test/file.lua" }, { input = "/Users/test/normal_path.lua", expected = "/Users/test/normal_path.lua" }, } for i, test_case in ipairs(test_cases) do local mock_mini_files = { get_fs_entry = function(buf_id) if buf_id ~= 1 then return nil end return { path = test_case.input } end, } package.loaded["mini.files"] = mock_mini_files local files, err = integrations._get_mini_files_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be(test_case.expected) end end) it("should handle empty entry under cursor", function() -- Mock mini.files module local mock_mini_files = { get_fs_entry = function() return nil -- No entry end, } package.loaded["mini.files"] = mock_mini_files local files, err = integrations._get_mini_files_selection() expect(err).to_be("Failed to get entry from mini.files") expect(files).to_be_table() expect(#files).to_be(0) end) it("should handle entry with empty path", function() -- Mock mini.files module local mock_mini_files = { get_fs_entry = function() return { path = "" } -- Empty path end, } package.loaded["mini.files"] = mock_mini_files local files, err = integrations._get_mini_files_selection() expect(err).to_be("No file found under cursor") expect(files).to_be_table() expect(#files).to_be(0) end) it("should handle invalid file path", function() -- Mock mini.files module local mock_mini_files = { get_fs_entry = function() return { path = "/Users/test/project/invalid_file" } end, } package.loaded["mini.files"] = mock_mini_files mock_vim.fn.filereadable = function() return 0 -- File not readable end mock_vim.fn.isdirectory = function() return 0 -- Not a directory end local files, err = integrations._get_mini_files_selection() expect(err).to_be("Invalid file or directory path: /Users/test/project/invalid_file") expect(files).to_be_table() expect(#files).to_be(0) end) it("should handle mini.files not available", function() -- Don't mock mini.files module (will cause require to fail) package.loaded["mini.files"] = nil local files, err = integrations._get_mini_files_selection() expect(err).to_be("mini.files not available") expect(files).to_be_table() expect(#files).to_be(0) end) it("should handle pcall errors gracefully", function() -- Mock mini.files module that throws errors local mock_mini_files = { get_fs_entry = function() error("mini.files get_fs_entry failed") end, } package.loaded["mini.files"] = mock_mini_files local files, err = integrations._get_mini_files_selection() expect(err).to_be("Failed to get entry from mini.files") expect(files).to_be_table() expect(#files).to_be(0) end) end) describe("get_selected_files_from_tree", function() it("should detect minifiles filetype and delegate to _get_mini_files_selection", function() mock_vim.bo.filetype = "minifiles" -- Mock mini.files module local mock_mini_files = { get_fs_entry = function() return { path = "/path/test.lua" } end, } package.loaded["mini.files"] = mock_mini_files local files, err = integrations.get_selected_files_from_tree() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/path/test.lua") end) it("should return error for unsupported filetype", function() mock_vim.bo.filetype = "unsupported" local files, err = integrations.get_selected_files_from_tree() assert_contains(err, "Not in a supported tree buffer") expect(files).to_be_nil() end) end) end) ================================================ FILE: tests/unit/native_terminal_toggle_spec.lua ================================================ describe("claudecode.terminal.native toggle behavior", function() local native_provider local mock_vim local logger_spy before_each(function() -- Set up the package path for tests package.path = "./lua/?.lua;" .. package.path -- Clean up any loaded modules package.loaded["claudecode.terminal.native"] = nil package.loaded["claudecode.logger"] = nil -- Mock state for more realistic testing local mock_state = { buffers = {}, windows = {}, current_win = 1, next_bufnr = 1, next_winid = 1000, next_jobid = 10000, buffer_options = {}, } -- Mock vim API with stateful behavior mock_vim = { api = { nvim_buf_is_valid = function(bufnr) return mock_state.buffers[bufnr] ~= nil end, nvim_win_is_valid = function(winid) return mock_state.windows[winid] ~= nil end, nvim_list_wins = function() local wins = {} for winid, _ in pairs(mock_state.windows) do table.insert(wins, winid) end return wins end, nvim_list_bufs = function() local bufs = {} for bufnr, _ in pairs(mock_state.buffers) do table.insert(bufs, bufnr) end return bufs end, nvim_buf_get_name = function(bufnr) local buf = mock_state.buffers[bufnr] return buf and buf.name or "" end, nvim_buf_get_option = function(bufnr, option) local buf = mock_state.buffers[bufnr] if buf and buf.options and buf.options[option] then return buf.options[option] end return "" end, nvim_buf_set_option = function(bufnr, option, value) local buf = mock_state.buffers[bufnr] if buf then buf.options = buf.options or {} buf.options[option] = value -- Track calls for verification mock_state.buffer_options[bufnr] = mock_state.buffer_options[bufnr] or {} mock_state.buffer_options[bufnr][option] = value end end, nvim_win_get_buf = function(winid) local win = mock_state.windows[winid] return win and win.bufnr or 0 end, nvim_win_close = function(winid, force) -- Remove window from state (simulates window closing) if winid and mock_state.windows[winid] then mock_state.windows[winid] = nil end end, nvim_get_current_win = function() return mock_state.current_win end, nvim_get_current_buf = function() local current_win = mock_state.current_win local win = mock_state.windows[current_win] return win and win.bufnr or 0 end, nvim_set_current_win = function(winid) if mock_state.windows[winid] then mock_state.current_win = winid end end, nvim_win_set_buf = function(winid, bufnr) local win = mock_state.windows[winid] if win and mock_state.buffers[bufnr] then win.bufnr = bufnr end end, nvim_win_set_height = function(winid, height) -- Mock window resizing end, nvim_win_set_width = function(winid, width) -- Mock window resizing end, nvim_win_call = function(winid, fn) -- Mock window-specific function execution return fn() end, }, cmd = function(command) -- Handle vsplit and other commands if command:match("^topleft %d+vsplit") or command:match("^botright %d+vsplit") then -- Create new window local winid = mock_state.next_winid mock_state.next_winid = mock_state.next_winid + 1 mock_state.windows[winid] = { bufnr = 0 } mock_state.current_win = winid elseif command == "enew" then -- Create new buffer in current window local bufnr = mock_state.next_bufnr mock_state.next_bufnr = mock_state.next_bufnr + 1 mock_state.buffers[bufnr] = { name = "", options = {} } if mock_state.windows[mock_state.current_win] then mock_state.windows[mock_state.current_win].bufnr = bufnr end end end, o = { columns = 120, lines = 40, }, fn = { termopen = function(cmd, opts) local jobid = mock_state.next_jobid mock_state.next_jobid = mock_state.next_jobid + 1 -- Create terminal buffer local bufnr = mock_state.next_bufnr mock_state.next_bufnr = mock_state.next_bufnr + 1 mock_state.buffers[bufnr] = { name = "term://claude", options = { buftype = "terminal", bufhidden = "wipe" }, jobid = jobid, on_exit = opts.on_exit, } -- Set buffer in current window if mock_state.windows[mock_state.current_win] then mock_state.windows[mock_state.current_win].bufnr = bufnr end return jobid end, }, schedule = function(callback) callback() -- Execute immediately in tests end, bo = setmetatable({}, { __index = function(_, bufnr) return setmetatable({}, { __newindex = function(_, option, value) -- Mock buffer option setting local buf = mock_state.buffers[bufnr] if buf then buf.options = buf.options or {} buf.options[option] = value end end, __index = function(_, option) local buf = mock_state.buffers[bufnr] return buf and buf.options and buf.options[option] or "" end, }) end, }), } _G.vim = mock_vim -- Mock logger logger_spy = { debug = function(module, message, ...) -- Track debug calls for verification end, error = function(module, message, ...) -- Track error calls end, } package.loaded["claudecode.logger"] = logger_spy -- Load the native provider native_provider = require("claudecode.terminal.native") native_provider.setup({}) -- Helper function to get mock state for verification _G.get_mock_state = function() return mock_state end end) after_each(function() _G.vim = nil package.loaded["claudecode.terminal.native"] = nil package.loaded["claudecode.logger"] = nil end) describe("toggle with no existing terminal", function() it("should create a new terminal when none exists", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Mock termopen to succeed mock_vim.fn.termopen = function(cmd, opts) assert.are.equal(cmd_string, cmd[1]) assert.are.same(env_table, opts.env) return 12345 -- Valid job ID end native_provider.toggle(cmd_string, env_table, config) -- Should have created terminal and have active buffer assert.is_not_nil(native_provider.get_active_bufnr()) end) end) describe("toggle with existing hidden terminal", function() it("should show hidden terminal instead of creating new one", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- First create a terminal mock_vim.fn.termopen = function(cmd, opts) return 12345 -- Valid job ID end native_provider.open(cmd_string, env_table, config) local initial_bufnr = native_provider.get_active_bufnr() assert.is_not_nil(initial_bufnr) -- Simulate hiding the terminal (buffer exists but no window shows it) mock_vim.api.nvim_list_wins = function() return { 1, 3 } -- Window 2 (which had our buffer) is gone end mock_vim.api.nvim_win_get_buf = function(winid) return 50 -- Other windows have different buffers end -- Mock window creation for showing hidden terminal local vsplit_called = false local original_cmd = mock_vim.cmd mock_vim.cmd = function(command) if command:match("vsplit") then vsplit_called = true end original_cmd(command) end mock_vim.api.nvim_get_current_win = function() return 4 -- New window created end -- Toggle should show the hidden terminal native_provider.toggle(cmd_string, env_table, config) -- Should not have created a new buffer/job, just shown existing one assert.are.equal(initial_bufnr, native_provider.get_active_bufnr()) assert.is_true(vsplit_called) end) end) describe("toggle with visible terminal", function() it("should hide terminal when toggling from inside it and set bufhidden=hide", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Create a terminal by opening it native_provider.open(cmd_string, env_table, config) local initial_bufnr = native_provider.get_active_bufnr() assert.is_not_nil(initial_bufnr) local mock_state = _G.get_mock_state() -- Verify initial state - buffer should exist and have a window assert.is_not_nil(mock_state.buffers[initial_bufnr]) assert.are.equal("hide", mock_state.buffers[initial_bufnr].options.bufhidden) -- Find the window that contains our terminal buffer local terminal_winid = nil for winid, win in pairs(mock_state.windows) do if win.bufnr == initial_bufnr then terminal_winid = winid break end end assert.is_not_nil(terminal_winid) -- Mock that we're currently in the terminal window mock_state.current_win = terminal_winid -- Toggle should hide the terminal native_provider.toggle(cmd_string, env_table, config) -- Verify the critical behavior: -- 1. Buffer should still exist and be valid assert.are.equal(initial_bufnr, native_provider.get_active_bufnr()) assert.is_not_nil(mock_state.buffers[initial_bufnr]) -- 2. Window should be closed/invalid assert.is_nil(mock_state.windows[terminal_winid]) end) it("should focus terminal when focus toggling from outside it", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Create a terminal native_provider.open(cmd_string, env_table, config) local initial_bufnr = native_provider.get_active_bufnr() local mock_state = _G.get_mock_state() -- Find the terminal window that was created local terminal_winid = nil for winid, win in pairs(mock_state.windows) do if win.bufnr == initial_bufnr then terminal_winid = winid break end end assert.is_not_nil(terminal_winid) -- Mock that we're NOT in the terminal window (simulate being in a different window) mock_state.current_win = 1 -- Some other window local set_current_win_called = false local focused_winid = nil local original_set_current_win = mock_vim.api.nvim_set_current_win mock_vim.api.nvim_set_current_win = function(winid) set_current_win_called = true focused_winid = winid return original_set_current_win(winid) end -- Focus toggle should focus the terminal native_provider.focus_toggle(cmd_string, env_table, config) -- Should have focused the terminal window assert.is_true(set_current_win_called) assert.are.equal(terminal_winid, focused_winid) assert.are.equal(initial_bufnr, native_provider.get_active_bufnr()) end) end) describe("close vs toggle behavior", function() it("should preserve process on toggle but kill on close", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Create a terminal native_provider.open(cmd_string, env_table, config) local initial_bufnr = native_provider.get_active_bufnr() assert.is_not_nil(initial_bufnr) local mock_state = _G.get_mock_state() -- Find the terminal window local terminal_winid = nil for winid, win in pairs(mock_state.windows) do if win.bufnr == initial_bufnr then terminal_winid = winid break end end -- Mock being in terminal window mock_state.current_win = terminal_winid -- Toggle should hide but preserve process native_provider.toggle(cmd_string, env_table, config) assert.are.equal(initial_bufnr, native_provider.get_active_bufnr()) -- Close should kill the process (cleanup_state called) native_provider.close() assert.is_nil(native_provider.get_active_bufnr()) end) end) describe("simple_toggle behavior", function() it("should always hide terminal when visible, regardless of focus", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Create a terminal native_provider.open(cmd_string, env_table, config) local initial_bufnr = native_provider.get_active_bufnr() local mock_state = _G.get_mock_state() -- Find the terminal window local terminal_winid = nil for winid, win in pairs(mock_state.windows) do if win.bufnr == initial_bufnr then terminal_winid = winid break end end -- Test 1: Not in terminal window - simple_toggle should still hide mock_state.current_win = 1 -- Different window native_provider.simple_toggle(cmd_string, env_table, config) -- Should have hidden the terminal (closed window) assert.is_nil(mock_state.windows[terminal_winid]) end) it("should always show terminal when not visible", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Start with no terminal assert.is_nil(native_provider.get_active_bufnr()) -- Simple toggle should create new terminal native_provider.simple_toggle(cmd_string, env_table, config) -- Should have created terminal assert.is_not_nil(native_provider.get_active_bufnr()) end) it("should show hidden terminal when toggled", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Create and then hide a terminal native_provider.open(cmd_string, env_table, config) local initial_bufnr = native_provider.get_active_bufnr() native_provider.simple_toggle(cmd_string, env_table, config) -- Hide it -- Mock window creation for showing hidden terminal local vsplit_called = false local original_cmd = mock_vim.cmd mock_vim.cmd = function(command) if command:match("vsplit") then vsplit_called = true end original_cmd(command) end -- Simple toggle should show the hidden terminal native_provider.simple_toggle(cmd_string, env_table, config) -- Should have shown the existing terminal assert.are.equal(initial_bufnr, native_provider.get_active_bufnr()) assert.is_true(vsplit_called) end) end) describe("focus_toggle behavior", function() it("should focus terminal when visible but not focused", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Create a terminal native_provider.open(cmd_string, env_table, config) local initial_bufnr = native_provider.get_active_bufnr() local mock_state = _G.get_mock_state() -- Find the terminal window local terminal_winid = nil for winid, win in pairs(mock_state.windows) do if win.bufnr == initial_bufnr then terminal_winid = winid break end end -- Mock that we're NOT in the terminal window mock_state.current_win = 1 -- Some other window local set_current_win_called = false local focused_winid = nil local original_set_current_win = mock_vim.api.nvim_set_current_win mock_vim.api.nvim_set_current_win = function(winid) set_current_win_called = true focused_winid = winid return original_set_current_win(winid) end -- Focus toggle should focus the terminal native_provider.focus_toggle(cmd_string, env_table, config) -- Should have focused the terminal window assert.is_true(set_current_win_called) assert.are.equal(terminal_winid, focused_winid) end) it("should hide terminal when focused and toggle called", function() local cmd_string = "claude" local env_table = { TEST = "value" } local config = { split_side = "right", split_width_percentage = 0.3 } -- Create a terminal native_provider.open(cmd_string, env_table, config) local initial_bufnr = native_provider.get_active_bufnr() local mock_state = _G.get_mock_state() -- Find the terminal window local terminal_winid = nil for winid, win in pairs(mock_state.windows) do if win.bufnr == initial_bufnr then terminal_winid = winid break end end -- Mock being in the terminal window mock_state.current_win = terminal_winid -- Focus toggle should hide the terminal native_provider.focus_toggle(cmd_string, env_table, config) -- Should have hidden the terminal assert.is_nil(mock_state.windows[terminal_winid]) end) end) end) ================================================ FILE: tests/unit/nvim_tree_visual_selection_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("NvimTree Visual Selection", function() local visual_commands local mock_vim local function setup_mocks() package.loaded["claudecode.visual_commands"] = nil package.loaded["claudecode.logger"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function() end, error = function() end, } mock_vim = { fn = { mode = function() return "V" -- Visual line mode end, getpos = function(mark) if mark == "'<" then return { 0, 2, 0, 0 } -- Start at line 2 elseif mark == "'>" then return { 0, 4, 0, 0 } -- End at line 4 elseif mark == "v" then return { 0, 2, 0, 0 } -- Anchor at line 2 end return { 0, 0, 0, 0 } end, }, api = { nvim_get_current_win = function() return 1002 end, nvim_get_mode = function() return { mode = "V" } end, nvim_get_current_buf = function() return 1 end, nvim_win_get_cursor = function() return { 4, 0 } -- Cursor at line 4 end, nvim_buf_get_lines = function(buf, start, end_line, strict) -- Return mock buffer lines for the visual selection return { " 📁 src/", " 📄 init.lua", " 📄 config.lua", } end, nvim_win_set_cursor = function(win, pos) -- Mock cursor setting end, nvim_replace_termcodes = function(keys, from_part, do_lt, special) return keys end, }, bo = { filetype = "NvimTree" }, schedule = function(fn) fn() end, } _G.vim = mock_vim end before_each(function() setup_mocks() end) describe("nvim-tree visual selection handling", function() before_each(function() visual_commands = require("claudecode.visual_commands") end) it("should extract files from visual selection in nvim-tree", function() -- Create a stateful mock that tracks cursor position local cursor_positions = {} local expected_nodes = { [2] = { type = "directory", absolute_path = "/Users/test/project/src" }, [3] = { type = "file", absolute_path = "/Users/test/project/init.lua" }, [4] = { type = "file", absolute_path = "/Users/test/project/config.lua" }, } mock_vim.api.nvim_win_set_cursor = function(win, pos) cursor_positions[#cursor_positions + 1] = pos[1] end local mock_nvim_tree_api = { tree = { get_node_under_cursor = function() local current_line = cursor_positions[#cursor_positions] or 2 return expected_nodes[current_line] end, }, } local visual_data = { tree_state = mock_nvim_tree_api, tree_type = "nvim-tree", start_pos = 2, end_pos = 4, } local files, err = visual_commands.get_files_from_visual_selection(visual_data) expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(3) expect(files[1]).to_be("/Users/test/project/src") expect(files[2]).to_be("/Users/test/project/init.lua") expect(files[3]).to_be("/Users/test/project/config.lua") end) it("should handle empty visual selection in nvim-tree", function() local mock_nvim_tree_api = { tree = { get_node_under_cursor = function() return nil -- No node found end, }, } local visual_data = { tree_state = mock_nvim_tree_api, tree_type = "nvim-tree", start_pos = 2, end_pos = 2, } local files, err = visual_commands.get_files_from_visual_selection(visual_data) expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(0) end) it("should filter out root-level files in nvim-tree", function() local mock_nvim_tree_api = { tree = { get_node_under_cursor = function() return { type = "file", absolute_path = "/root_file.txt", -- Root-level file should be filtered } end, }, } local visual_data = { tree_state = mock_nvim_tree_api, tree_type = "nvim-tree", start_pos = 1, end_pos = 1, } local files, err = visual_commands.get_files_from_visual_selection(visual_data) expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(0) -- Root-level file should be filtered out end) it("should remove duplicate files in visual selection", function() local call_count = 0 local mock_nvim_tree_api = { tree = { get_node_under_cursor = function() call_count = call_count + 1 -- Return the same file path twice to test deduplication return { type = "file", absolute_path = "/Users/test/project/duplicate.lua", } end, }, } local visual_data = { tree_state = mock_nvim_tree_api, tree_type = "nvim-tree", start_pos = 1, end_pos = 2, -- Two lines, same file } local files, err = visual_commands.get_files_from_visual_selection(visual_data) expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) -- Should have only one instance expect(files[1]).to_be("/Users/test/project/duplicate.lua") end) it("should handle mixed file and directory selection", function() local cursor_positions = {} local expected_nodes = { [1] = { type = "directory", absolute_path = "/Users/test/project/lib" }, [2] = { type = "file", absolute_path = "/Users/test/project/main.lua" }, [3] = { type = "directory", absolute_path = "/Users/test/project/tests" }, } mock_vim.api.nvim_win_set_cursor = function(win, pos) cursor_positions[#cursor_positions + 1] = pos[1] end local mock_nvim_tree_api = { tree = { get_node_under_cursor = function() local current_line = cursor_positions[#cursor_positions] or 1 return expected_nodes[current_line] end, }, } local visual_data = { tree_state = mock_nvim_tree_api, tree_type = "nvim-tree", start_pos = 1, end_pos = 3, } local files, err = visual_commands.get_files_from_visual_selection(visual_data) expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(3) expect(files[1]).to_be("/Users/test/project/lib") expect(files[2]).to_be("/Users/test/project/main.lua") expect(files[3]).to_be("/Users/test/project/tests") end) end) end) ================================================ FILE: tests/unit/oil_integration_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("oil.nvim integration", function() local integrations local mock_vim local function setup_mocks() package.loaded["claudecode.integrations"] = nil package.loaded["claudecode.visual_commands"] = nil package.loaded["claudecode.logger"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function() end, error = function() end, } mock_vim = { fn = { mode = function() return "n" -- Default to normal mode end, line = function(mark) if mark == "'<" then return 2 elseif mark == "'>" then return 4 end return 1 end, }, api = { nvim_get_current_buf = function() return 1 end, nvim_win_get_cursor = function() return { 4, 0 } end, nvim_get_mode = function() return { mode = "n" } end, }, bo = { filetype = "oil" }, } _G.vim = mock_vim end before_each(function() setup_mocks() integrations = require("claudecode.integrations") end) describe("_get_oil_selection", function() it("should get single file under cursor in normal mode", function() local mock_oil = { get_cursor_entry = function() return { type = "file", name = "main.lua" } end, get_current_dir = function(bufnr) return "/Users/test/project/" end, } package.loaded["oil"] = mock_oil local files, err = integrations._get_oil_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/main.lua") end) it("should get directory under cursor in normal mode", function() local mock_oil = { get_cursor_entry = function() return { type = "directory", name = "src" } end, get_current_dir = function(bufnr) return "/Users/test/project/" end, } package.loaded["oil"] = mock_oil local files, err = integrations._get_oil_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/src/") end) it("should skip parent directory entries", function() local mock_oil = { get_cursor_entry = function() return { type = "directory", name = ".." } end, get_current_dir = function(bufnr) return "/Users/test/project/" end, } package.loaded["oil"] = mock_oil local files, err = integrations._get_oil_selection() expect(err).to_be("No file found under cursor") expect(files).to_be_table() expect(#files).to_be(0) end) it("should handle symbolic links", function() local mock_oil = { get_cursor_entry = function() return { type = "link", name = "linked_file.lua" } end, get_current_dir = function(bufnr) return "/Users/test/project/" end, } package.loaded["oil"] = mock_oil local files, err = integrations._get_oil_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/Users/test/project/linked_file.lua") end) it("should handle visual mode selection", function() -- Mock visual mode mock_vim.fn.mode = function() return "V" end mock_vim.api.nvim_get_mode = function() return { mode = "V" } end -- Mock visual_commands module package.loaded["claudecode.visual_commands"] = { get_visual_range = function() return 2, 4 -- Lines 2 to 4 end, } local line_entries = { [2] = { type = "file", name = "file1.lua" }, [3] = { type = "directory", name = "src" }, [4] = { type = "file", name = "file2.lua" }, } local mock_oil = { get_current_dir = function(bufnr) return "/Users/test/project/" end, get_entry_on_line = function(bufnr, line) return line_entries[line] end, } package.loaded["oil"] = mock_oil local files, err = integrations._get_oil_selection() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(3) expect(files[1]).to_be("/Users/test/project/file1.lua") expect(files[2]).to_be("/Users/test/project/src/") expect(files[3]).to_be("/Users/test/project/file2.lua") end) it("should handle errors gracefully", function() local mock_oil = { get_cursor_entry = function() error("Failed to get cursor entry") end, } package.loaded["oil"] = mock_oil local files, err = integrations._get_oil_selection() expect(err).to_be("Failed to get cursor entry") expect(files).to_be_table() expect(#files).to_be(0) end) it("should handle missing oil.nvim", function() package.loaded["oil"] = nil local files, err = integrations._get_oil_selection() expect(err).to_be("oil.nvim not available") expect(files).to_be_table() expect(#files).to_be(0) end) end) describe("get_selected_files_from_tree", function() it("should detect oil filetype and delegate to _get_oil_selection", function() mock_vim.bo.filetype = "oil" local mock_oil = { get_cursor_entry = function() return { type = "file", name = "test.lua" } end, get_current_dir = function(bufnr) return "/path/" end, } package.loaded["oil"] = mock_oil local files, err = integrations.get_selected_files_from_tree() expect(err).to_be_nil() expect(files).to_be_table() expect(#files).to_be(1) expect(files[1]).to_be("/path/test.lua") end) end) end) ================================================ FILE: tests/unit/opendiff_blocking_spec.lua ================================================ -- Unit test for openDiff blocking behavior -- This test directly calls the openDiff handler to verify blocking behavior describe("openDiff blocking behavior", function() local open_diff_module local mock_logger before_each(function() -- Set up minimal vim mock require("tests.helpers.setup") -- Mock logger mock_logger = { debug = spy.new(function() end), error = spy.new(function() end), info = spy.new(function() end), } package.loaded["claudecode.logger"] = mock_logger -- Mock diff module to prevent loading issues package.loaded["claudecode.diff"] = { open_diff_blocking = function() error("This should not be called in coroutine context test") end, } -- Load the module under test open_diff_module = require("claudecode.tools.open_diff") end) after_each(function() -- Clean up package.loaded["claudecode.logger"] = nil package.loaded["claudecode.tools.open_diff"] = nil package.loaded["claudecode.diff"] = nil end) it("should require coroutine context", function() -- Test that openDiff fails when not in coroutine context local params = { old_file_path = "/tmp/test.txt", new_file_path = "/tmp/test.txt", new_file_contents = "test content", tab_name = "test tab", } -- This should error because we're not in a coroutine local success, err = pcall(open_diff_module.handler, params) assert.is_false(success) assert.is_table(err) assert.equals(-32000, err.code) assert.matches("coroutine context", err.data) end) it("should block in coroutine context", function() -- Create test file local test_file = "/tmp/opendiff_test.txt" local file = io.open(test_file, "w") file:write("original content\n") file:close() local params = { old_file_path = test_file, new_file_path = test_file, new_file_contents = "modified content\n", tab_name = "✻ [Test] test.txt ⧉", } local co_finished = false local error_occurred = false local test_error = nil -- Create coroutine that calls openDiff local co = coroutine.create(function() local success, result = pcall(open_diff_module.handler, params) if not success then error_occurred = true test_error = result end co_finished = true end) -- Start the coroutine local success = coroutine.resume(co) assert.is_true(success) -- In test environment, the diff setup may fail due to missing vim APIs -- This is expected and doesn't indicate a problem with the blocking logic if error_occurred then -- Verify it's failing for expected reasons (missing vim APIs, not logic errors) assert.is_true(type(test_error) == "table" or type(test_error) == "string") -- Test passes - openDiff correctly requires full vim environment else -- If it didn't error, it should be blocking assert.is_false(co_finished, "Coroutine should not finish immediately - it should block") assert.equals("suspended", coroutine.status(co)) -- Test passes - openDiff properly blocks in coroutine context end -- Check that some logging occurred (openDiff attempts logging even if it fails) -- In test environment, this might not always be called due to early failures if not error_occurred then assert.spy(mock_logger.debug).was_called() end -- Cleanup os.remove(test_file) end) it("should handle file not found error", function() local params = { old_file_path = "/nonexistent/file.txt", new_file_path = "/nonexistent/file.txt", new_file_contents = "content", tab_name = "test tab", } local co = coroutine.create(function() return open_diff_module.handler(params) end) local success, err = coroutine.resume(co) -- Should fail because file doesn't exist assert.is_false(success) assert.is_table(err) assert.equals(-32000, err.code) -- Error gets wrapped by open_diff_blocking -- The exact error message may vary depending on where it fails in the test environment assert.is_true( err.message == "Error setting up diff" or err.message == "Internal server error" or err.message == "Error opening blocking diff" ) end) it("should validate required parameters", function() local test_cases = { {}, -- empty params { old_file_path = "/tmp/test.txt" }, -- missing new_file_path { old_file_path = "/tmp/test.txt", new_file_path = "/tmp/test.txt" }, -- missing new_file_contents { old_file_path = "/tmp/test.txt", new_file_path = "/tmp/test.txt", new_file_contents = "content" }, -- missing tab_name } for i, params in ipairs(test_cases) do local co = coroutine.create(function() return open_diff_module.handler(params) end) local success, err = coroutine.resume(co) assert.is_false(success, "Test case " .. i .. " should fail validation") assert.is_table(err, "Test case " .. i .. " should return structured error") assert.equals(-32602, err.code, "Test case " .. i .. " should return invalid params error") end end) end) ================================================ FILE: tests/unit/server_spec.lua ================================================ -- Unit tests for WebSocket server module -- luacheck: globals expect require("tests.busted_setup") describe("WebSocket Server", function() local server -- Set up before each test local function setup() -- Reset loaded modules package.loaded["claudecode.server.init"] = nil -- Also update package.loaded key -- Load the module under test server = require("claudecode.server.init") end -- Clean up after each test local function teardown() -- Ensure server is stopped if server.state.server then server.stop() end end -- Run setup before each test setup() it("should have a get_status function", function() local status = server.get_status() expect(status).to_be_table() expect(status.running).to_be_false() expect(status.port).to_be_nil() expect(status.client_count).to_be(0) end) it("should start server successfully", function() local config = { port_range = { min = 10000, max = 65535, }, } local success, port = server.start(config) expect(success).to_be_true() expect(server.state.server).to_be_table() expect(server.state.port).to_be(port) expect(port >= config.port_range.min and port <= config.port_range.max).to_be_true() -- Clean up server.stop() end) it("should not start server twice", function() local config = { port_range = { min = 10000, max = 65535, }, } -- Start once local success1, _ = server.start(config) expect(success1).to_be_true() -- Try to start again local success2, error2 = server.start(config) expect(success2).to_be_false() expect(error2).to_be("Server already running") -- Clean up server.stop() end) it("should stop server successfully", function() local config = { port_range = { min = 10000, max = 65535, }, } -- Start server server.start(config) -- Stop server local success, _ = server.stop() expect(success).to_be_true() expect(server.state.server).to_be_nil() expect(server.state.port).to_be_nil() expect(server.state.clients).to_be_table() expect(#server.state.clients).to_be(0) end) it("should not stop server if not running", function() -- Ensure server is not running if server.state.server then server.stop() end -- Try to stop again local success, error = server.stop() expect(success).to_be_false() expect(error).to_be("Server not running") end) it("should register message handlers", function() server.register_handlers() expect(server.state.handlers).to_be_table() expect(type(server.state.handlers["initialize"])).to_be("function") -- Function, not table expect(type(server.state.handlers["tools/list"])).to_be("function") -- Function, not table end) it("should send message to client", function() -- Start server first local config = { port_range = { min = 10000, max = 65535 } } server.start(config) -- Mock client local client = { id = "test_client" } local method = "test_method" local params = { foo = "bar" } local success = server.send(client, method, params) expect(success).to_be_true() -- Clean up server.stop() end) it("should send response to client", function() -- Start server first local config = { port_range = { min = 10000, max = 65535 } } server.start(config) -- Mock client local client = { id = "test_client" } local id = "test_id" local result = { foo = "bar" } local success = server.send_response(client, id, result) expect(success).to_be_true() -- Clean up server.stop() end) it("should broadcast to all clients", function() -- Start server first local config = { port_range = { min = 10000, max = 65535 } } server.start(config) local method = "test_method" local params = { foo = "bar" } local success = server.broadcast(method, params) expect(success).to_be_true() -- Clean up server.stop() end) describe("authentication integration", function() it("should start server with authentication token", function() local config = { port_range = { min = 10000, max = 65535, }, } local auth_token = "550e8400-e29b-41d4-a716-446655440000" local success, port = server.start(config, auth_token) expect(success).to_be_true() expect(server.state.auth_token).to_be(auth_token) expect(server.state.server).to_be_table() expect(server.state.port).to_be(port) -- Clean up server.stop() end) it("should clear auth token when server stops", function() local config = { port_range = { min = 10000, max = 65535, }, } local auth_token = "550e8400-e29b-41d4-a716-446655440000" -- Start server with auth token server.start(config, auth_token) expect(server.state.auth_token).to_be(auth_token) -- Stop server server.stop() expect(server.state.auth_token).to_be_nil() end) it("should start server without authentication token", function() local config = { port_range = { min = 10000, max = 65535, }, } local success, port = server.start(config, nil) expect(success).to_be_true() expect(server.state.auth_token).to_be_nil() expect(server.state.server).to_be_table() expect(server.state.port).to_be(port) -- Clean up server.stop() end) it("should pass auth token to TCP server creation", function() local config = { port_range = { min = 10000, max = 65535, }, } local auth_token = "550e8400-e29b-41d4-a716-446655440000" -- Mock the TCP server module to verify auth token is passed local tcp_server = require("claudecode.server.tcp") local original_create_server = tcp_server.create_server local captured_auth_token = nil tcp_server.create_server = function(cfg, callbacks, auth_token_arg) captured_auth_token = auth_token_arg return original_create_server(cfg, callbacks, auth_token_arg) end local success, _ = server.start(config, auth_token) -- Restore original function tcp_server.create_server = original_create_server expect(success).to_be_true() expect(captured_auth_token).to_be(auth_token) -- Clean up server.stop() end) it("should maintain auth token in server state throughout lifecycle", function() local config = { port_range = { min = 10000, max = 65535, }, } local auth_token = "550e8400-e29b-41d4-a716-446655440000" -- Start server server.start(config, auth_token) expect(server.state.auth_token).to_be(auth_token) -- Get status should show running state local status = server.get_status() expect(status.running).to_be_true() expect(server.state.auth_token).to_be(auth_token) -- Send message should work with auth token in place local client = { id = "test_client" } local success = server.send(client, "test_method", { test = "data" }) expect(success).to_be_true() expect(server.state.auth_token).to_be(auth_token) -- Clean up server.stop() end) it("should reject starting server if auth token is explicitly false", function() local config = { port_range = { min = 10000, max = 65535, }, } -- Use an empty string as invalid auth token local success, _ = server.start(config, "") expect(success).to_be_true() -- Server should still start, just with empty token expect(server.state.auth_token).to_be("") -- Clean up server.stop() end) end) -- Clean up after all tests teardown() end) ================================================ FILE: tests/unit/terminal_spec.lua ================================================ describe("claudecode.terminal (wrapper for Snacks.nvim)", function() local terminal_wrapper local spy local mock_snacks_module local mock_snacks_terminal local mock_claudecode_config_module local mock_snacks_provider local mock_native_provider local last_created_mock_term_instance local create_mock_terminal_instance create_mock_terminal_instance = function(cmd, opts) --- Internal deepcopy for the mock's own use. --- Avoids recursion with spied vim.deepcopy. local function internal_deepcopy(tbl) if type(tbl) ~= "table" then return tbl end local status, plenary_tablex = pcall(require, "plenary.tablex") if status and plenary_tablex and plenary_tablex.deepcopy then return plenary_tablex.deepcopy(tbl) end local lookup_table = {} local function _copy(object) if type(object) ~= "table" then return object elseif lookup_table[object] then return lookup_table[object] end local new_table = {} lookup_table[object] = new_table for index, value in pairs(object) do new_table[_copy(index)] = _copy(value) end return setmetatable(new_table, getmetatable(object)) end return _copy(tbl) end local instance = { winid = 1000 + math.random(100), buf = 2000 + math.random(100), _is_valid = true, _cmd_received = cmd, _opts_received = internal_deepcopy(opts), _on_close_callback = nil, valid = spy.new(function(self) return self._is_valid end), focus = spy.new(function(self) end), close = spy.new(function(self) if self._is_valid and self._on_close_callback then self._on_close_callback({ winid = self.winid }) end self._is_valid = false end), } instance.win = instance.winid if opts and opts.win and opts.win.on_close then instance._on_close_callback = opts.win.on_close end last_created_mock_term_instance = instance return instance end before_each(function() _G.vim = require("tests.mocks.vim") local spy_instance_methods = {} local spy_instance_mt = { __index = spy_instance_methods } local function internal_deepcopy_for_spy_calls(tbl) if type(tbl) ~= "table" then return tbl end local status_plenary, plenary_tablex_local = pcall(require, "plenary.tablex") if status_plenary and plenary_tablex_local and plenary_tablex_local.deepcopy then return plenary_tablex_local.deepcopy(tbl) end local lookup_table_local = {} local function _copy_local(object) if type(object) ~= "table" then return object elseif lookup_table_local[object] then return lookup_table_local[object] end local new_table_local = {} lookup_table_local[object] = new_table_local for index, value in pairs(object) do new_table_local[_copy_local(index)] = _copy_local(value) end return setmetatable(new_table_local, getmetatable(object)) end return _copy_local(tbl) end spy_instance_mt.__call = function(self, ...) table.insert(self.calls, { refs = internal_deepcopy_for_spy_calls({ ... }) }) if self.fake_fn then return self.fake_fn(unpack({ ... })) end end function spy_instance_methods:reset() self.calls = {} end function spy_instance_methods:clear() self:reset() end function spy_instance_methods:was_called(count) local actual_count = #self.calls if count then assert( actual_count == count, string.format("Expected spy to be called %d time(s), but was called %d time(s).", count, actual_count) ) else assert(actual_count > 0, "Expected spy to be called at least once, but was not called.") end end function spy_instance_methods:was_not_called() assert(#self.calls == 0, string.format("Expected spy not to be called, but was called %d time(s).", #self.calls)) end function spy_instance_methods:was_called_with(...) local expected_args = { ... } local found_match = false local calls_repr = "" for i, call_info in ipairs(self.calls) do calls_repr = calls_repr .. "\n Call " .. i .. ": {" for j, arg_ref in ipairs(call_info.refs) do calls_repr = calls_repr .. tostring(arg_ref) .. (j < #call_info.refs and ", " or "") end calls_repr = calls_repr .. "}" end if #self.calls == 0 and #expected_args == 0 then found_match = true elseif #self.calls > 0 then for _, call_info in ipairs(self.calls) do local actual_args = call_info.refs if #actual_args == #expected_args then local current_match = true for i = 1, #expected_args do if type(expected_args[i]) == "table" and getmetatable(expected_args[i]) and getmetatable(expected_args[i]).__is_matcher then if not expected_args[i](actual_args[i]) then current_match = false break end elseif actual_args[i] ~= expected_args[i] then current_match = false break end end if current_match then found_match = true break end end end end local expected_repr = "" for i, arg in ipairs(expected_args) do expected_repr = expected_repr .. tostring(arg) .. (i < #expected_args and ", " or "") end assert( found_match, "Spy was not called with the expected arguments.\nExpected: {" .. expected_repr .. "}\nActual Calls:" .. calls_repr ) end function spy_instance_methods:get_call(index) return self.calls[index] end spy = { new = function(fake_fn) local s = { calls = {}, fake_fn = fake_fn } setmetatable(s, spy_instance_mt) return s end, on = function(tbl, key) local original_fn = tbl[key] local spy_obj = spy.new(original_fn) tbl[key] = spy_obj return spy_obj end, restore = function() end, matching = { is_type = function(expected_type) local matcher_table = { __is_matcher = true } matcher_table.__call = function(self, val) return type(val) == expected_type end setmetatable(matcher_table, matcher_table) return matcher_table end, string = { match = function(pattern) local matcher_table = { __is_matcher = true } matcher_table.__call = function(self, actual_str) if type(actual_str) ~= "string" then return false end return actual_str:match(pattern) ~= nil end setmetatable(matcher_table, matcher_table) return matcher_table end, }, }, } package.loaded["claudecode.terminal"] = nil package.loaded["claudecode.terminal.snacks"] = nil package.loaded["claudecode.terminal.native"] = nil package.loaded["claudecode.server.init"] = nil package.loaded["snacks"] = nil package.loaded["claudecode.config"] = nil package.loaded["claudecode.logger"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function(context, message) vim.notify(message, vim.log.levels.WARN) end, error = function(context, message) vim.notify(message, vim.log.levels.ERROR) end, } -- Mock the server module local mock_server_module = { state = { port = 12345 }, } package.loaded["claudecode.server.init"] = mock_server_module mock_claudecode_config_module = { apply = spy.new(function(user_conf) local base_config = { terminal_cmd = "claude" } if user_conf and user_conf.terminal_cmd then base_config.terminal_cmd = user_conf.terminal_cmd end return base_config end), } package.loaded["claudecode.config"] = mock_claudecode_config_module -- Mock the provider modules mock_snacks_provider = { setup = spy.new(function() end), open = spy.new(create_mock_terminal_instance), close = spy.new(function() end), toggle = spy.new(function(cmd, env_table, config, opts_override) return create_mock_terminal_instance(cmd, { env = env_table }) end), simple_toggle = spy.new(function(cmd, env_table, config, opts_override) return create_mock_terminal_instance(cmd, { env = env_table }) end), focus_toggle = spy.new(function(cmd, env_table, config, opts_override) return create_mock_terminal_instance(cmd, { env = env_table }) end), get_active_bufnr = spy.new(function() return nil end), is_available = spy.new(function() return true end), _get_terminal_for_test = spy.new(function() return last_created_mock_term_instance end), } package.loaded["claudecode.terminal.snacks"] = mock_snacks_provider mock_native_provider = { setup = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), toggle = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return nil end), is_available = spy.new(function() return true end), } package.loaded["claudecode.terminal.native"] = mock_native_provider mock_snacks_terminal = { open = spy.new(create_mock_terminal_instance), toggle = spy.new(function(cmd, opts) local existing_term = terminal_wrapper and terminal_wrapper._get_managed_terminal_for_test and terminal_wrapper._get_managed_terminal_for_test() if existing_term and existing_term._cmd_received == cmd then if existing_term._on_close_callback then existing_term._on_close_callback({ winid = existing_term.winid }) end return nil end return create_mock_terminal_instance(cmd, opts) end), } mock_snacks_module = { terminal = mock_snacks_terminal } package.loaded["snacks"] = mock_snacks_module vim.g.claudecode_user_config = {} local original_mock_vim_deepcopy = _G.vim.deepcopy _G.vim.deepcopy = spy.new(function(tbl) if original_mock_vim_deepcopy then return original_mock_vim_deepcopy(tbl) else if type(tbl) ~= "table" then return tbl end local status_plenary, plenary_tablex_local = pcall(require, "plenary.tablex") if status_plenary and plenary_tablex_local and plenary_tablex_local.deepcopy then return plenary_tablex_local.deepcopy(tbl) end local lookup_table_local = {} local function _copy_local(object) if type(object) ~= "table" then return object elseif lookup_table_local[object] then return lookup_table_local[object] end local new_table_local = {} lookup_table_local[object] = new_table_local for index, value in pairs(object) do new_table_local[_copy_local(index)] = _copy_local(value) end return setmetatable(new_table_local, getmetatable(object)) end return _copy_local(tbl) end end) vim.api.nvim_buf_get_option = spy.new(function(_bufnr, opt_name) if opt_name == "buftype" then return "terminal" end return nil end) vim.api.nvim_win_call = spy.new(function(_winid, func) func() end) vim.cmd = spy.new(function(_cmd_str) end) vim.notify = spy.new(function(_msg, _level) end) terminal_wrapper = require("claudecode.terminal") -- Don't call setup({}) here to allow custom provider tests to work end) after_each(function() package.loaded["claudecode.terminal"] = nil package.loaded["claudecode.terminal.snacks"] = nil package.loaded["claudecode.terminal.native"] = nil package.loaded["claudecode.server.init"] = nil package.loaded["snacks"] = nil package.loaded["claudecode.config"] = nil package.loaded["claudecode.logger"] = nil if _G.vim and _G.vim._mock and _G.vim._mock.reset then _G.vim._mock.reset() end _G.vim = nil last_created_mock_term_instance = nil end) describe("terminal.setup", function() it("should store valid split_side and split_width_percentage", function() terminal_wrapper.setup({ split_side = "left", split_width_percentage = 0.5 }) terminal_wrapper.open() local config_arg = mock_snacks_provider.open:get_call(1).refs[3] assert.are.equal("left", config_arg.split_side) assert.are.equal(0.5, config_arg.split_width_percentage) end) it("should ignore invalid split_side and use default", function() terminal_wrapper.setup({ split_side = "invalid_side", split_width_percentage = 0.5 }) terminal_wrapper.open() local config_arg = mock_snacks_provider.open:get_call(1).refs[3] assert.are.equal("right", config_arg.split_side) assert.are.equal(0.5, config_arg.split_width_percentage) vim.notify:was_called_with(spy.matching.string.match("Invalid value for split_side"), vim.log.levels.WARN) end) it("should ignore invalid split_width_percentage and use default", function() terminal_wrapper.setup({ split_side = "left", split_width_percentage = 2.0 }) terminal_wrapper.open() local config_arg = mock_snacks_provider.open:get_call(1).refs[3] assert.are.equal("left", config_arg.split_side) assert.are.equal(0.30, config_arg.split_width_percentage) vim.notify:was_called_with( spy.matching.string.match("Invalid value for split_width_percentage"), vim.log.levels.WARN ) end) it("should ignore unknown keys", function() terminal_wrapper.setup({ unknown_key = "some_value", split_side = "left" }) terminal_wrapper.open() local config_arg = mock_snacks_provider.open:get_call(1).refs[3] assert.are.equal("left", config_arg.split_side) vim.notify:was_called_with( spy.matching.string.match("Unknown configuration key: unknown_key"), vim.log.levels.WARN ) end) it("should use defaults if user_term_config is not a table and notify", function() terminal_wrapper.setup("not_a_table") terminal_wrapper.open() local config_arg = mock_snacks_provider.open:get_call(1).refs[3] assert.are.equal("right", config_arg.split_side) assert.are.equal(0.30, config_arg.split_width_percentage) vim.notify:was_called_with( "claudecode.terminal.setup expects a table or nil for user_term_config", vim.log.levels.WARN ) end) end) describe("terminal.open", function() it( "should call Snacks.terminal.open with default 'claude' command if terminal_cmd is not set in main config", function() vim.g.claudecode_user_config = {} mock_claudecode_config_module.apply = spy.new(function() return { terminal_cmd = "claude" } end) package.loaded["claudecode.config"] = mock_claudecode_config_module package.loaded["claudecode.terminal"] = nil terminal_wrapper = require("claudecode.terminal") terminal_wrapper.setup({}) terminal_wrapper.open() mock_snacks_provider.open:was_called(1) local cmd_arg = mock_snacks_provider.open:get_call(1).refs[1] local env_arg = mock_snacks_provider.open:get_call(1).refs[2] local config_arg = mock_snacks_provider.open:get_call(1).refs[3] assert.are.equal("claude", cmd_arg) assert.is_table(env_arg) assert.are.equal("true", env_arg.ENABLE_IDE_INTEGRATION) assert.is_table(config_arg) assert.are.equal("right", config_arg.split_side) assert.are.equal(0.30, config_arg.split_width_percentage) end ) it("should call Snacks.terminal.open with terminal_cmd from main config", function() vim.g.claudecode_user_config = { terminal_cmd = "my_claude_cli" } mock_claudecode_config_module.apply = spy.new(function() return { terminal_cmd = "my_claude_cli" } end) package.loaded["claudecode.config"] = mock_claudecode_config_module package.loaded["claudecode.terminal"] = nil terminal_wrapper = require("claudecode.terminal") terminal_wrapper.setup({}, "my_claude_cli") terminal_wrapper.open() mock_snacks_provider.open:was_called(1) local cmd_arg = mock_snacks_provider.open:get_call(1).refs[1] assert.are.equal("my_claude_cli", cmd_arg) end) it("should call provider open twice when terminal exists", function() terminal_wrapper.open() local first_instance = last_created_mock_term_instance assert.is_not_nil(first_instance) -- Provider manages its own state, so we expect open to be called again terminal_wrapper.open() mock_snacks_provider.open:was_called(2) -- Called twice: once to create, once for existing check end) it("should apply opts_override to snacks_opts when opening a new terminal", function() terminal_wrapper.open({ split_side = "left", split_width_percentage = 0.6 }) mock_snacks_provider.open:was_called(1) local config_arg = mock_snacks_provider.open:get_call(1).refs[3] assert.are.equal("left", config_arg.split_side) assert.are.equal(0.6, config_arg.split_width_percentage) end) it("should call provider open and handle nil return gracefully", function() mock_snacks_provider.open = spy.new(function() -- Simulate provider handling its own failure notification vim.notify("Failed to open Claude terminal using Snacks.", vim.log.levels.ERROR) return nil end) vim.notify:reset() terminal_wrapper.open() vim.notify:was_called_with("Failed to open Claude terminal using Snacks.", vim.log.levels.ERROR) mock_snacks_provider.open:reset() mock_snacks_provider.open = spy.new(function() vim.notify("Failed to open Claude terminal using Snacks.", vim.log.levels.ERROR) return nil end) terminal_wrapper.open() mock_snacks_provider.open:was_called(1) end) it("should call provider open and handle invalid instance gracefully", function() local invalid_instance = { valid = spy.new(function() return false end) } mock_snacks_provider.open = spy.new(function() -- Simulate provider handling its own failure notification vim.notify("Failed to open Claude terminal using Snacks.", vim.log.levels.ERROR) return invalid_instance end) vim.notify:reset() terminal_wrapper.open() vim.notify:was_called_with("Failed to open Claude terminal using Snacks.", vim.log.levels.ERROR) mock_snacks_provider.open:reset() mock_snacks_provider.open = spy.new(function() vim.notify("Failed to open Claude terminal using Snacks.", vim.log.levels.ERROR) return invalid_instance end) terminal_wrapper.open() mock_snacks_provider.open:was_called(1) end) end) describe("terminal.close", function() it("should call managed_terminal:close() if valid terminal exists", function() terminal_wrapper.open() mock_snacks_provider.open:was_called(1) terminal_wrapper.close() mock_snacks_provider.close:was_called(1) end) it("should call provider close even if no managed terminal", function() terminal_wrapper.close() mock_snacks_provider.close:was_called(1) mock_snacks_provider.open:was_not_called() end) it("should not call close if managed terminal is invalid", function() terminal_wrapper.open() local current_managed_term = last_created_mock_term_instance assert.is_not_nil(current_managed_term) current_managed_term._is_valid = false current_managed_term.close:reset() terminal_wrapper.close() current_managed_term.close:was_not_called() end) end) describe("terminal.toggle", function() it("should call Snacks.terminal.toggle with correct command and options", function() vim.g.claudecode_user_config = { terminal_cmd = "toggle_claude" } mock_claudecode_config_module.apply = spy.new(function() return { terminal_cmd = "toggle_claude" } end) package.loaded["claudecode.config"] = mock_claudecode_config_module package.loaded["claudecode.terminal"] = nil terminal_wrapper = require("claudecode.terminal") terminal_wrapper.setup({ split_side = "left", split_width_percentage = 0.4 }, "toggle_claude") terminal_wrapper.toggle({ split_width_percentage = 0.45 }) mock_snacks_provider.simple_toggle:was_called(1) local cmd_arg = mock_snacks_provider.simple_toggle:get_call(1).refs[1] local config_arg = mock_snacks_provider.simple_toggle:get_call(1).refs[3] assert.are.equal("toggle_claude", cmd_arg) assert.are.equal("left", config_arg.split_side) assert.are.equal(0.45, config_arg.split_width_percentage) end) it("should call provider toggle and manage state", function() local mock_toggled_instance = create_mock_terminal_instance("toggled_cmd", {}) mock_snacks_provider.simple_toggle = spy.new(function() return mock_toggled_instance end) terminal_wrapper.toggle({}) mock_snacks_provider.simple_toggle:was_called(1) -- After toggle, subsequent open should work with provider state terminal_wrapper.open() mock_snacks_provider.open:was_called(1) end) it("should set managed_snacks_terminal to nil if toggle returns nil", function() mock_snacks_terminal.toggle = spy.new(function() return nil end) terminal_wrapper.toggle({}) mock_snacks_provider.open:reset() terminal_wrapper.open() mock_snacks_provider.open:was_called(1) end) end) describe("provider callback handling", function() it("should handle terminal closure through provider", function() terminal_wrapper.open() local opened_instance = last_created_mock_term_instance assert.is_not_nil(opened_instance) -- Simulate terminal closure via provider's close method terminal_wrapper.close() mock_snacks_provider.close:was_called(1) end) it("should create new terminal after closure", function() terminal_wrapper.open() mock_snacks_provider.open:was_called(1) terminal_wrapper.close() mock_snacks_provider.close:was_called(1) mock_snacks_provider.open:reset() terminal_wrapper.open() mock_snacks_provider.open:was_called(1) end) end) describe("command arguments support", function() it("should append cmd_args to base command when provided to open", function() terminal_wrapper.open({}, "--resume") mock_snacks_provider.open:was_called(1) local cmd_arg = mock_snacks_provider.open:get_call(1).refs[1] assert.are.equal("claude --resume", cmd_arg) end) it("should append cmd_args to base command when provided to toggle", function() terminal_wrapper.toggle({}, "--resume --verbose") mock_snacks_provider.simple_toggle:was_called(1) local cmd_arg = mock_snacks_provider.simple_toggle:get_call(1).refs[1] assert.are.equal("claude --resume --verbose", cmd_arg) end) it("should work with custom terminal_cmd and arguments", function() terminal_wrapper.setup({}, "my_claude_binary") terminal_wrapper.open({}, "--flag") mock_snacks_provider.open:was_called(1) local cmd_arg = mock_snacks_provider.open:get_call(1).refs[1] assert.are.equal("my_claude_binary --flag", cmd_arg) end) it("should fallback gracefully when cmd_args is nil", function() terminal_wrapper.open({}, nil) mock_snacks_provider.open:was_called(1) local cmd_arg = mock_snacks_provider.open:get_call(1).refs[1] assert.are.equal("claude", cmd_arg) end) it("should fallback gracefully when cmd_args is empty string", function() terminal_wrapper.toggle({}, "") mock_snacks_provider.simple_toggle:was_called(1) local cmd_arg = mock_snacks_provider.simple_toggle:get_call(1).refs[1] assert.are.equal("claude", cmd_arg) end) it("should work with both opts_override and cmd_args", function() terminal_wrapper.open({ split_side = "left" }, "--resume") mock_snacks_provider.open:was_called(1) local cmd_arg = mock_snacks_provider.open:get_call(1).refs[1] local config_arg = mock_snacks_provider.open:get_call(1).refs[3] assert.are.equal("claude --resume", cmd_arg) assert.are.equal("left", config_arg.split_side) end) it("should handle special characters in arguments", function() terminal_wrapper.open({}, "--message='hello world'") mock_snacks_provider.open:was_called(1) local cmd_arg = mock_snacks_provider.open:get_call(1).refs[1] assert.are.equal("claude --message='hello world'", cmd_arg) end) it("should maintain backward compatibility when no cmd_args provided", function() terminal_wrapper.open() mock_snacks_provider.open:was_called(1) local open_cmd = mock_snacks_provider.open:get_call(1).refs[1] assert.are.equal("claude", open_cmd) -- Close the existing terminal and reset spies to test toggle in isolation terminal_wrapper.close() mock_snacks_provider.open:reset() mock_snacks_terminal.toggle:reset() terminal_wrapper.toggle() mock_snacks_provider.simple_toggle:was_called(1) local toggle_cmd = mock_snacks_provider.simple_toggle:get_call(1).refs[1] assert.are.equal("claude", toggle_cmd) end) end) describe("custom table provider functionality", function() describe("valid custom provider", function() it("should call setup method during terminal wrapper setup", function() local setup_spy = spy.new(function() end) local custom_provider = { setup = setup_spy, open = spy.new(function() end), close = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return 123 end), is_available = spy.new(function() return true end), } terminal_wrapper.setup({ provider = custom_provider }) setup_spy:was_called(1) setup_spy:was_called_with(spy.matching.is_type("table")) end) it("should check is_available during open operation", function() local is_available_spy = spy.new(function() return true end) local open_spy = spy.new(function() end) local custom_provider = { setup = spy.new(function() end), open = open_spy, close = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return 123 end), is_available = is_available_spy, } terminal_wrapper.setup({ provider = custom_provider }) terminal_wrapper.open() is_available_spy:was_called() open_spy:was_called() end) it("should auto-generate toggle function if missing", function() local simple_toggle_spy = spy.new(function() end) local custom_provider = { setup = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), simple_toggle = simple_toggle_spy, focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return 123 end), is_available = spy.new(function() return true end), -- Note: toggle function is intentionally missing } terminal_wrapper.setup({ provider = custom_provider }) -- Verify that toggle function was auto-generated and calls simple_toggle assert.is_function(custom_provider.toggle) local test_env = {} local test_config = {} custom_provider.toggle("test_cmd", test_env, test_config) simple_toggle_spy:was_called(1) -- Check that the first argument (command string) is correct local call_args = simple_toggle_spy:get_call(1).refs assert.are.equal("test_cmd", call_args[1]) assert.are.equal(3, #call_args) -- Should have 3 arguments end) it("should auto-generate _get_terminal_for_test function if missing", function() local custom_provider = { setup = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return 123 end), is_available = spy.new(function() return true end), -- Note: _get_terminal_for_test function is intentionally missing } terminal_wrapper.setup({ provider = custom_provider }) -- Verify that _get_terminal_for_test function was auto-generated assert.is_function(custom_provider._get_terminal_for_test) assert.is_nil(custom_provider._get_terminal_for_test()) end) it("should pass correct parameters to custom provider functions", function() local open_spy = spy.new(function() end) local simple_toggle_spy = spy.new(function() end) local focus_toggle_spy = spy.new(function() end) local custom_provider = { setup = spy.new(function() end), open = open_spy, close = spy.new(function() end), simple_toggle = simple_toggle_spy, focus_toggle = focus_toggle_spy, get_active_bufnr = spy.new(function() return 123 end), is_available = spy.new(function() return true end), } terminal_wrapper.setup({ provider = custom_provider }) -- Test open with parameters terminal_wrapper.open({ split_side = "left" }, "test_args") open_spy:was_called() local open_call = open_spy:get_call(1) assert.is_string(open_call.refs[1]) -- cmd_string assert.is_table(open_call.refs[2]) -- env_table assert.is_table(open_call.refs[3]) -- effective_config -- Test simple_toggle with parameters terminal_wrapper.simple_toggle({ split_width_percentage = 0.4 }, "toggle_args") simple_toggle_spy:was_called() local toggle_call = simple_toggle_spy:get_call(1) assert.is_string(toggle_call.refs[1]) -- cmd_string assert.is_table(toggle_call.refs[2]) -- env_table assert.is_table(toggle_call.refs[3]) -- effective_config end) end) describe("fallback behavior", function() it("should fallback to native provider when is_available returns false", function() local custom_provider = { setup = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return 123 end), is_available = spy.new(function() return false end), -- Returns false } terminal_wrapper.setup({ provider = custom_provider }) terminal_wrapper.open() -- Should use native provider instead mock_native_provider.open:was_called() custom_provider.open:was_not_called() end) it("should fallback to native provider when is_available throws error", function() local custom_provider = { setup = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return 123 end), is_available = spy.new(function() error("Availability check failed") end), } terminal_wrapper.setup({ provider = custom_provider }) terminal_wrapper.open() -- Should use native provider instead mock_native_provider.open:was_called() custom_provider.open:was_not_called() end) end) describe("invalid provider rejection", function() it("should reject non-table providers", function() -- Make snacks provider unavailable to force fallback to native mock_snacks_provider.is_available = spy.new(function() return false end) mock_native_provider.open:reset() -- Reset the spy before the test terminal_wrapper.setup({ provider = "invalid_string" }) -- Check that vim.notify was called with the expected warning about invalid value local notify_calls = vim.notify.calls local found_warning = false for _, call in ipairs(notify_calls) do local message = call.refs[1] if message and message:match("Invalid value for provider.*invalid_string") then found_warning = true break end end assert.is_true(found_warning, "Expected warning about invalid provider value") terminal_wrapper.open() -- Should fallback to native provider (since snacks is unavailable and invalid string was rejected) mock_native_provider.open:was_called() end) it("should reject providers missing required functions", function() local incomplete_provider = { setup = function() end, open = function() end, -- Missing other required functions } terminal_wrapper.setup({ provider = incomplete_provider }) terminal_wrapper.open() -- Should fallback to native provider mock_native_provider.open:was_called() end) it("should reject providers with non-function required fields", function() local invalid_provider = { setup = function() end, open = "not_a_function", -- Invalid type close = function() end, simple_toggle = function() end, focus_toggle = function() end, get_active_bufnr = function() return 123 end, is_available = function() return true end, } terminal_wrapper.setup({ provider = invalid_provider }) terminal_wrapper.open() -- Should fallback to native provider mock_native_provider.open:was_called() end) end) describe("wrapper function invocations", function() it("should properly invoke all wrapper functions with custom provider", function() local custom_provider = { setup = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), simple_toggle = spy.new(function() end), focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return 456 end), is_available = spy.new(function() return true end), } terminal_wrapper.setup({ provider = custom_provider }) -- Test all wrapper functions terminal_wrapper.open() custom_provider.open:was_called() terminal_wrapper.close() custom_provider.close:was_called() terminal_wrapper.simple_toggle() custom_provider.simple_toggle:was_called() terminal_wrapper.focus_toggle() custom_provider.focus_toggle:was_called() local bufnr = terminal_wrapper.get_active_terminal_bufnr() custom_provider.get_active_bufnr:was_called() assert.are.equal(456, bufnr) end) it("should handle toggle function (legacy) correctly", function() local simple_toggle_spy = spy.new(function() end) local custom_provider = { setup = spy.new(function() end), open = spy.new(function() end), close = spy.new(function() end), simple_toggle = simple_toggle_spy, focus_toggle = spy.new(function() end), get_active_bufnr = spy.new(function() return 123 end), is_available = spy.new(function() return true end), } terminal_wrapper.setup({ provider = custom_provider }) -- Legacy toggle should call simple_toggle terminal_wrapper.toggle() simple_toggle_spy:was_called() end) end) end) describe("custom provider validation", function() it("should reject provider missing required functions", function() local invalid_provider = { setup = function() end } -- missing other functions terminal_wrapper.setup({ provider = invalid_provider }) terminal_wrapper.open() -- Verify fallback to native provider mock_native_provider.open:was_called() -- Check that the warning was logged (vim.notify gets called with logger output) local notify_calls = vim.notify.calls local found_warning = false for _, call in ipairs(notify_calls) do local message = call.refs[1] if message and message:match("Invalid custom table provider.*missing required function") then found_warning = true break end end assert.is_true(found_warning, "Expected warning about missing required function") end) it("should handle provider availability check failures", function() local provider_with_error = { setup = function() end, open = function() end, close = function() end, simple_toggle = function() end, focus_toggle = function() end, get_active_bufnr = function() return 123 end, is_available = function() error("test error") end, } vim.notify:reset() terminal_wrapper.setup({ provider = provider_with_error }) terminal_wrapper.open() -- Verify graceful fallback to native provider mock_native_provider.open:was_called() -- Check that the warning was logged about availability error local notify_calls = vim.notify.calls local found_warning = false for _, call in ipairs(notify_calls) do local message = call.refs[1] if message and message:match("error checking availability") then found_warning = true break end end assert.is_true(found_warning, "Expected warning about availability check error") end) it("should validate provider function types", function() local invalid_provider = { setup = function() end, open = "not_a_function", -- Wrong type close = function() end, simple_toggle = function() end, focus_toggle = function() end, get_active_bufnr = function() return 123 end, is_available = function() return true end, } vim.notify:reset() terminal_wrapper.setup({ provider = invalid_provider }) terminal_wrapper.open() -- Should fallback to native provider mock_native_provider.open:was_called() -- Check for function type validation error local notify_calls = vim.notify.calls local found_error = false for _, call in ipairs(notify_calls) do local message = call.refs[1] if message and message:match("must be callable.*got.*string") then found_error = true break end end assert.is_true(found_error, "Expected error about function type validation") end) it("should verify fallback on availability check failure", function() local provider_unavailable = { setup = function() end, open = function() end, close = function() end, simple_toggle = function() end, focus_toggle = function() end, get_active_bufnr = function() return 123 end, is_available = function() return false end, -- Provider says it's not available } vim.notify:reset() terminal_wrapper.setup({ provider = provider_unavailable }) terminal_wrapper.open() -- Should use native provider mock_native_provider.open:was_called() -- Check for availability warning local notify_calls = vim.notify.calls local found_warning = false for _, call in ipairs(notify_calls) do local message = call.refs[1] if message and message:match("provider reports not available") then found_warning = true break end end assert.is_true(found_warning, "Expected warning about provider not available") end) it("should test auto-generated optional functions with working provider", function() local simple_toggle_called = false local provider_minimal = { setup = function() end, open = function() end, close = function() end, simple_toggle = function() simple_toggle_called = true end, focus_toggle = function() end, get_active_bufnr = function() return 123 end, is_available = function() return true end, -- Missing toggle and _get_terminal_for_test functions } terminal_wrapper.setup({ provider = provider_minimal }) -- Test auto-generated toggle function assert.is_function(provider_minimal.toggle) provider_minimal.toggle("test_cmd", { TEST = "env" }, { split_side = "left" }) assert.is_true(simple_toggle_called) -- Test auto-generated _get_terminal_for_test function assert.is_function(provider_minimal._get_terminal_for_test) assert.is_nil(provider_minimal._get_terminal_for_test()) end) it("should handle edge case where provider returns nil for required function", function() local provider_with_nil_function = { setup = function() end, open = function() end, close = nil, -- Explicitly nil instead of missing simple_toggle = function() end, focus_toggle = function() end, get_active_bufnr = function() return 123 end, is_available = function() return true end, } vim.notify:reset() terminal_wrapper.setup({ provider = provider_with_nil_function }) terminal_wrapper.open() -- Should fallback to native provider mock_native_provider.open:was_called() -- Check for missing function error local notify_calls = vim.notify.calls local found_error = false for _, call in ipairs(notify_calls) do local message = call.refs[1] if message and message:match("missing required function.*close") then found_error = true break end end assert.is_true(found_error, "Expected error about missing close function") end) end) end) ================================================ FILE: tests/unit/tools_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("Tools Module", function() local tools local mock_vim local spy -- For spying on functions local function setup() package.loaded["claudecode.tools.init"] = nil package.loaded["claudecode.diff"] = nil package.loaded["luassert.spy"] = nil -- Ensure spy is fresh spy = require("luassert.spy") mock_vim = { fn = { expand = function(path) return path end, filereadable = function() return 1 end, fnameescape = function(path) return path end, bufnr = function() return 1 end, buflisted = function() return 1 end, getcwd = function() return "/test/workspace" end, fnamemodify = function(path, modifier) if modifier == ":t" then return "workspace" end return path end, }, cmd = function() end, api = { nvim_list_bufs = function() return { 1, 2 } end, nvim_buf_is_loaded = function() return true end, nvim_buf_get_name = function(bufnr) if bufnr == 1 then return "/test/file1.lua" end if bufnr == 2 then return "/test/file2.lua" end return "" end, nvim_buf_get_option = function() return false end, nvim_buf_call = function(bufnr, fn_to_call) -- Renamed to avoid conflict fn_to_call() end, nvim_buf_delete = function() end, }, lsp = {}, diagnostic = { get = function() return { { bufnr = 1, lnum = 10, col = 5, severity = 1, message = "Test error", source = "test", }, } end, }, json = { encode = function(obj) return vim.inspect(obj) -- Use the real vim.inspect if available, or our mock end, }, notify = function() end, log = { -- Add mock for vim.log levels = { TRACE = 0, DEBUG = 1, ERROR = 2, WARN = 3, -- Add other common levels for completeness if needed INFO = 4, }, }, inspect = function(obj) -- Keep the mock inspect for controlled output if type(obj) == "string" then return '"' .. obj .. '"' elseif type(obj) == "table" then local items = {} for k, v in pairs(obj) do table.insert(items, tostring(k) .. ": " .. mock_vim.inspect(v)) end return "{" .. table.concat(items, ", ") .. "}" else return tostring(obj) end end, } _G.vim = mock_vim tools = require("claudecode.tools.init") -- Ensure tools are registered for testing handle_invoke tools.register_all() end local function teardown() _G.vim = nil package.loaded["luassert.spy"] = nil spy = nil end local function contains(str, pattern) if type(str) ~= "string" or type(pattern) ~= "string" then return false end return str:find(pattern, 1, true) ~= nil end before_each(function() setup() end) after_each(function() teardown() end) describe("Tool Registration", function() it("should register all tools", function() -- tools.register_all() is called in setup expect(tools.tools).to_be_table() expect(tools.tools.openFile).to_be_table() expect(tools.tools.openFile.handler).to_be_function() expect(tools.tools.getDiagnostics).to_be_table() expect(tools.tools.getDiagnostics.handler).to_be_function() expect(tools.tools.getOpenEditors).to_be_table() expect(tools.tools.getOpenEditors.handler).to_be_function() expect(tools.tools.openDiff).to_be_table() expect(tools.tools.openDiff.handler).to_be_function() -- Add more checks for other registered tools as needed end) it("should allow registering custom tools", function() local custom_tool_handler = spy.new(function() return "custom result" end) local custom_tool_module = { name = "customTool", schema = nil, handler = custom_tool_handler, } tools.register(custom_tool_module) expect(tools.tools.customTool.handler).to_be(custom_tool_handler) end) end) describe("Tool Invocation Handler (handle_invoke)", function() it("should handle valid tool invocation and return result (e.g., getOpenEditors)", function() -- The 'tools' module and its handlers were loaded in setup() when _G.vim was 'mock_vim'. -- So, we need to modify the spies on the 'mock_vim' instance directly. mock_vim.api.nvim_list_bufs = spy.new(function() return { 1 } end) mock_vim.api.nvim_buf_is_loaded = spy.new(function(b) return b == 1 end) mock_vim.fn.buflisted = spy.new(function(b) -- Ensure this is on mock_vim.fn if b == 1 then return 1 else return 0 end -- Must return number 0 or 1 end) mock_vim.api.nvim_buf_get_name = spy.new(function(b) if b == 1 then return "/test/file.lua" else return "" end end) mock_vim.api.nvim_buf_get_option = spy.new(function(b, opt) if b == 1 and opt == "modified" then return false elseif b == 1 and opt == "filetype" then return "lua" else return nil end end) mock_vim.api.nvim_get_current_buf = spy.new(function() return 1 end) mock_vim.api.nvim_get_current_tabpage = spy.new(function() return 1 end) mock_vim.api.nvim_buf_line_count = spy.new(function(b) if b == 1 then return 100 end return 0 end) mock_vim.fn.fnamemodify = spy.new(function(path, modifier) if modifier == ":t" then return path:match("[^/]+$") or path end return path end) mock_vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) -- Mock selection module to prevent errors package.loaded["claudecode.selection"] = { get_latest_selection = function() return nil end, } -- Re-register the specific tool to ensure its handler picks up the new spies package.loaded["claudecode.tools.get_open_editors"] = nil -- Clear cache for the sub-tool tools.register(require("claudecode.tools.get_open_editors")) local params = { name = "getOpenEditors", arguments = {}, } local result_obj = tools.handle_invoke(nil, params) expect(result_obj.result).to_be_table() -- "Expected .result to be a table" expect(result_obj.result.content).to_be_table() -- "Expected .result.content to be a table" expect(result_obj.result.content[1]).to_be_table() expect(result_obj.result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result_obj.result.content[1].text) expect(parsed_result.tabs).to_be_table() expect(#parsed_result.tabs).to_be(1) expect(parsed_result.tabs[1].uri).to_be("file:///test/file.lua") expect(parsed_result.tabs[1].label).to_be("file.lua") expect(result_obj.error).to_be_nil() -- "Expected .error to be nil for successful call" expect(mock_vim.api.nvim_list_bufs.calls).to_be_table() -- Check if .calls table exists expect(#mock_vim.api.nvim_list_bufs.calls > 0).to_be_true() -- Then, check if called expect(mock_vim.api.nvim_buf_is_loaded.calls[1].vals[1]).to_be(1) -- Check first arg of first call expect(mock_vim.fn.buflisted.calls[1].vals[1]).to_be(1) -- Check first arg of first call expect(mock_vim.api.nvim_buf_get_name.calls[1].vals[1]).to_be(1) -- Check first arg of first call expect(mock_vim.api.nvim_buf_get_option.calls[1].vals[1]).to_be(1) -- Check first arg of first call -- Check that both 'filetype' and 'modified' options were requested, regardless of order local get_option_calls = mock_vim.api.nvim_buf_get_option.calls local options_requested = {} for i = 1, #get_option_calls do table.insert(options_requested, get_option_calls[i].vals[2]) end local found_filetype = false local found_modified = false for _, v in ipairs(options_requested) do if v == "filetype" then found_filetype = true end if v == "modified" then found_modified = true end end expect(found_filetype).to_be_true("Expected 'filetype' option to be requested") expect(found_modified).to_be_true("Expected 'modified' option to be requested") -- Clean up selection module mock package.loaded["claudecode.selection"] = nil end) it("should handle unknown tool invocation with JSON-RPC error", function() local params = { name = "unknownTool", arguments = {}, } local result_obj = tools.handle_invoke(nil, params) expect(result_obj.error).to_be_table() expect(result_obj.error.code).to_be(-32601) -- Method not found expect(contains(result_obj.error.message, "Tool not found: unknownTool")).to_be_true() expect(result_obj.result).to_be_nil() end) it("should handle tool execution errors (structured error from handler) with JSON-RPC error", function() local erroring_tool_handler = spy.new(function() error({ code = -32001, message = "Specific tool error from handler", data = { detail = "some detail" } }) end) tools.register({ name = "errorToolStructured", schema = nil, handler = erroring_tool_handler, }) local params = { name = "errorToolStructured", arguments = {} } local result_obj = tools.handle_invoke(nil, params) expect(result_obj.error).to_be_table() expect(result_obj.error.code).to_be(-32001) expect(result_obj.error.message).to_be("Specific tool error from handler") expect(result_obj.error.data).to_be_table() expect(result_obj.error.data.detail).to_be("some detail") expect(result_obj.result).to_be_nil() expect(erroring_tool_handler.calls).to_be_table() expect(#erroring_tool_handler.calls > 0).to_be_true() end) it("should handle tool execution errors (simple string error from handler) with JSON-RPC error", function() local erroring_tool_handler_string = spy.new(function() error("Simple string error from tool handler") end) tools.register({ name = "errorToolString", schema = nil, handler = erroring_tool_handler_string, }) local params = { name = "errorToolString", arguments = {} } local result_obj = tools.handle_invoke(nil, params) expect(result_obj.error).to_be_table() expect(result_obj.error.code).to_be(-32000) -- Default server error for unhandled/string errors assert_contains(result_obj.error.message, "Simple string error from tool handler") -- Message includes traceback assert_contains(result_obj.error.data, "Simple string error from tool handler") -- Original error string in data expect(result_obj.result).to_be_nil() expect(erroring_tool_handler_string.calls).to_be_table() expect(#erroring_tool_handler_string.calls > 0).to_be_true() end) it("should handle tool execution errors (pcall/xpcall style error from handler) with JSON-RPC error", function() local erroring_tool_handler_pcall = spy.new(function() -- Simulate a tool that returns an error status and message, like from pcall return false, "Pcall-style error message" end) tools.register({ name = "errorToolPcallStyle", schema = nil, handler = erroring_tool_handler_pcall, }) local params = { name = "errorToolPcallStyle", arguments = {} } local result_obj = tools.handle_invoke(nil, params) expect(result_obj.error).to_be_table() expect(result_obj.error.code).to_be(-32000) -- Default server error expect(result_obj.error.message).to_be("Pcall-style error message") -- This should be exact as it's not passed through Lua's error() expect(result_obj.error.data).not_to_be_nil() -- "error.data should not be nil for pcall-style string errors" expect(type(result_obj.error.data)).to_be("string") -- Check type explicitly assert_contains(result_obj.error.data, "Pcall-style error message") expect(result_obj.result).to_be_nil() expect(erroring_tool_handler_pcall.calls).to_be_table() expect(#erroring_tool_handler_pcall.calls > 0).to_be_true() end) end) -- All individual tool describe blocks (e.g., "Open File Tool", "Get Diagnostics Tool", etc.) -- were removed from this file as of the refactoring on 2025-05-26. -- Their functionality is now tested in their respective spec files -- under tests/unit/tools/impl/. -- This file now focuses on the tool registration and the generic handle_invoke logic. teardown() end) ================================================ FILE: tests/unit/visual_delay_timing_spec.lua ================================================ -- luacheck: globals expect require("tests.busted_setup") describe("Visual Delay Timing Validation", function() local selection_module local mock_vim local function setup_mocks() package.loaded["claudecode.selection"] = nil package.loaded["claudecode.logger"] = nil package.loaded["claudecode.terminal"] = nil -- Mock logger package.loaded["claudecode.logger"] = { debug = function() end, warn = function() end, error = function() end, } -- Mock terminal package.loaded["claudecode.terminal"] = { get_active_terminal_bufnr = function() return nil -- No active terminal by default end, } -- Extend the existing vim mock mock_vim = _G.vim or {} -- Mock timing functions mock_vim.loop = mock_vim.loop or {} mock_vim._timers = {} mock_vim._timer_id = 0 mock_vim.loop.new_timer = function() mock_vim._timer_id = mock_vim._timer_id + 1 local timer = { id = mock_vim._timer_id, started = false, stopped = false, closed = false, callback = nil, delay = nil, } mock_vim._timers[timer.id] = timer return timer end -- Mock timer methods on the timer objects local timer_metatable = { __index = { start = function(self, delay, repeat_count, callback) self.started = true self.delay = delay self.callback = callback -- Immediately execute for testing if callback then callback() end end, stop = function(self) self.stopped = true end, close = function(self) self.closed = true mock_vim._timers[self.id] = nil end, }, } -- Apply metatable to all timers for _, timer in pairs(mock_vim._timers) do setmetatable(timer, timer_metatable) end -- Override new_timer to apply metatable to new timers local original_new_timer = mock_vim.loop.new_timer mock_vim.loop.new_timer = function() local timer = original_new_timer() setmetatable(timer, timer_metatable) return timer end mock_vim.loop.now = function() return os.time() * 1000 -- Mock timestamp in milliseconds end -- Mock vim.schedule_wrap mock_vim.schedule_wrap = function(callback) return callback end -- Mock mode functions mock_vim.api = mock_vim.api or {} mock_vim.api.nvim_get_mode = function() return { mode = "n" } -- Default to normal mode end mock_vim.api.nvim_get_current_buf = function() return 1 end _G.vim = mock_vim end before_each(function() setup_mocks() selection_module = require("claudecode.selection") end) describe("delay timing appropriateness", function() it("should use 50ms delay as default", function() expect(selection_module.state.visual_demotion_delay_ms).to_be(50) end) it("should allow configurable delay", function() local mock_server = { broadcast = function() return true end, } selection_module.enable(mock_server, 100) expect(selection_module.state.visual_demotion_delay_ms).to_be(100) end) it("should handle very short delays without issues", function() local mock_server = { broadcast = function() return true end, } selection_module.enable(mock_server, 10) expect(selection_module.state.visual_demotion_delay_ms).to_be(10) local success = pcall(function() selection_module.handle_selection_demotion(1) end) expect(success).to_be_true() end) it("should handle zero delay", function() local mock_server = { broadcast = function() return true end, } selection_module.enable(mock_server, 0) expect(selection_module.state.visual_demotion_delay_ms).to_be(0) local success = pcall(function() selection_module.handle_selection_demotion(1) end) expect(success).to_be_true() end) end) describe("performance characteristics", function() it("should not accumulate timers with rapid mode changes", function() local mock_server = { broadcast = function() return true end, } selection_module.enable(mock_server, 50) local initial_timer_count = 0 for _ in pairs(mock_vim._timers) do initial_timer_count = initial_timer_count + 1 end -- Simulate rapid visual mode entry/exit for i = 1, 10 do -- Mock visual selection selection_module.state.last_active_visual_selection = { bufnr = 1, selection_data = { selection = { isEmpty = false } }, timestamp = mock_vim.loop.now(), } -- Trigger update_selection selection_module.update_selection() end local final_timer_count = 0 for _ in pairs(mock_vim._timers) do final_timer_count = final_timer_count + 1 end -- Should not accumulate many timers expect(final_timer_count - initial_timer_count <= 1).to_be_true() end) it("should properly clean up timers", function() local mock_server = { broadcast = function() return true end, } selection_module.enable(mock_server, 50) -- Start a visual selection demotion selection_module.state.last_active_visual_selection = { bufnr = 1, selection_data = { selection = { isEmpty = false } }, timestamp = mock_vim.loop.now(), } -- Check if any timers exist before cleanup local found_timer = next(mock_vim._timers) ~= nil -- Disable selection tracking selection_module.disable() -- If a timer was found, it should be cleaned up -- This test is mainly about ensuring no errors occur during cleanup expect(found_timer == true or found_timer == false).to_be_true() -- Always passes, tests cleanup doesn't error end) end) describe("responsiveness analysis", function() it("50ms should be fast enough for tree navigation", function() -- 50ms is: -- - Faster than typical human reaction time (100-200ms) -- - Fast enough to feel immediate -- - Slow enough to allow deliberate actions local delay = 50 expect(delay < 100).to_be_true() -- Faster than reaction time expect(delay > 10).to_be_true() -- Not too aggressive end) it("should be configurable for different use cases", function() local mock_server = { broadcast = function() return true end, } -- Power users might want faster (25ms) selection_module.enable(mock_server, 25) expect(selection_module.state.visual_demotion_delay_ms).to_be(25) -- Disable and re-enable for different timing selection_module.disable() -- Slower systems might want more time (100ms) selection_module.enable(mock_server, 100) expect(selection_module.state.visual_demotion_delay_ms).to_be(100) end) end) describe("edge case behavior", function() it("should handle timer callback execution correctly", function() local mock_server = { broadcast = function() return true end, } selection_module.enable(mock_server, 50) -- Set up a visual selection that will trigger demotion selection_module.state.last_active_visual_selection = { bufnr = 1, selection_data = { selection = { isEmpty = false } }, timestamp = mock_vim.loop.now(), } selection_module.state.latest_selection = { bufnr = 1, selection = { isEmpty = false }, } -- Should not error when demotion callback executes local success = pcall(function() selection_module.update_selection() end) expect(success).to_be_true() end) end) end) ================================================ FILE: tests/unit/server/handshake_spec.lua ================================================ require("tests.busted_setup") describe("WebSocket handshake authentication", function() local handshake before_each(function() handshake = require("claudecode.server.handshake") end) after_each(function() package.loaded["claudecode.server.handshake"] = nil end) describe("validate_upgrade_request with authentication", function() local valid_request_base = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "", "", }, "\r\n") it("should accept valid request with correct auth token", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local request_with_auth = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "x-claude-code-ide-authorization: " .. expected_token, "", "", }, "\r\n") local is_valid, headers = handshake.validate_upgrade_request(request_with_auth, expected_token) assert.is_true(is_valid) assert.is_table(headers) assert.equals(expected_token, headers["x-claude-code-ide-authorization"]) end) it("should reject request with missing auth token when required", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local is_valid, error_msg = handshake.validate_upgrade_request(valid_request_base, expected_token) assert.is_false(is_valid) assert.equals("Missing authentication header: x-claude-code-ide-authorization", error_msg) end) it("should reject request with incorrect auth token", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local wrong_token = "123e4567-e89b-12d3-a456-426614174000" local request_with_wrong_auth = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "x-claude-code-ide-authorization: " .. wrong_token, "", "", }, "\r\n") local is_valid, error_msg = handshake.validate_upgrade_request(request_with_wrong_auth, expected_token) assert.is_false(is_valid) assert.equals("Invalid authentication token", error_msg) end) it("should accept request without auth token when none required", function() local is_valid, headers = handshake.validate_upgrade_request(valid_request_base, nil) assert.is_true(is_valid) assert.is_table(headers) end) it("should reject request with empty auth token when required", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local request_with_empty_auth = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "x-claude-code-ide-authorization: ", "", "", }, "\r\n") local is_valid, error_msg = handshake.validate_upgrade_request(request_with_empty_auth, expected_token) assert.is_false(is_valid) assert.equals("Authentication token too short (min 10 characters)", error_msg) end) it("should handle case-insensitive auth header name", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local request_with_uppercase_header = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "X-Claude-Code-IDE-Authorization: " .. expected_token, "", "", }, "\r\n") local is_valid, headers = handshake.validate_upgrade_request(request_with_uppercase_header, expected_token) assert.is_true(is_valid) assert.is_table(headers) end) end) describe("process_handshake with authentication", function() local valid_request_base = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "", "", }, "\r\n") it("should complete handshake successfully with valid auth token", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local request_with_auth = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "x-claude-code-ide-authorization: " .. expected_token, "", "", }, "\r\n") local success, response, headers = handshake.process_handshake(request_with_auth, expected_token) assert.is_true(success) assert.is_string(response) assert.is_table(headers) assert.matches("HTTP/1.1 101 Switching Protocols", response) assert.matches("Upgrade: websocket", response) assert.matches("Connection: Upgrade", response) assert.matches("Sec%-WebSocket%-Accept:", response) end) it("should fail handshake with missing auth token", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local success, response, headers = handshake.process_handshake(valid_request_base, expected_token) assert.is_false(success) assert.is_string(response) assert.is_nil(headers) assert.matches("HTTP/1.1 400 Bad Request", response) assert.matches("Missing authentication header", response) end) it("should fail handshake with invalid auth token", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local wrong_token = "123e4567-e89b-12d3-a456-426614174000" local request_with_wrong_auth = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "x-claude-code-ide-authorization: " .. wrong_token, "", "", }, "\r\n") local success, response, headers = handshake.process_handshake(request_with_wrong_auth, expected_token) assert.is_false(success) assert.is_string(response) assert.is_nil(headers) assert.matches("HTTP/1.1 400 Bad Request", response) assert.matches("Invalid authentication token", response) end) it("should complete handshake without auth when none required", function() local success, response, headers = handshake.process_handshake(valid_request_base, nil) assert.is_true(success) assert.is_string(response) assert.is_table(headers) assert.matches("HTTP/1.1 101 Switching Protocols", response) end) end) describe("authentication edge cases", function() it("should handle malformed auth header format", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local request_with_malformed_auth = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "x-claude-code-ide-authorization:not-a-uuid", "", "", }, "\r\n") local is_valid, error_msg = handshake.validate_upgrade_request(request_with_malformed_auth, expected_token) assert.is_false(is_valid) assert.equals("Invalid authentication token", error_msg) end) it("should handle multiple auth headers (uses last one)", function() local expected_token = "550e8400-e29b-41d4-a716-446655440000" local wrong_token = "123e4567-e89b-12d3-a456-426614174000" local request_with_multiple_auth = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "x-claude-code-ide-authorization: " .. wrong_token, "x-claude-code-ide-authorization: " .. expected_token, "", "", }, "\r\n") local is_valid, headers = handshake.validate_upgrade_request(request_with_multiple_auth, expected_token) assert.is_true(is_valid) assert.is_table(headers) assert.equals(expected_token, headers["x-claude-code-ide-authorization"]) end) it("should reject very long auth tokens", function() local expected_token = string.rep("a", 1000) -- Very long token local request_with_long_auth = table.concat({ "GET /websocket HTTP/1.1", "Host: localhost:8080", "Upgrade: websocket", "Connection: upgrade", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version: 13", "x-claude-code-ide-authorization: " .. expected_token, "", "", }, "\r\n") local is_valid, error_msg = handshake.validate_upgrade_request(request_with_long_auth, expected_token) assert.is_false(is_valid) assert.equals("Authentication token too long (max 500 characters)", error_msg) end) end) end) ================================================ FILE: tests/unit/tools/check_document_dirty_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: check_document_dirty", function() local check_document_dirty_handler before_each(function() package.loaded["claudecode.tools.check_document_dirty"] = nil check_document_dirty_handler = require("claudecode.tools.check_document_dirty").handler _G.vim = _G.vim or {} _G.vim.fn = _G.vim.fn or {} _G.vim.api = _G.vim.api or {} -- Mock vim.json.encode _G.vim.json = _G.vim.json or {} _G.vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) -- Default mocks _G.vim.fn.bufnr = spy.new(function(filePath) if filePath == "/path/to/open_file.lua" then return 1 end if filePath == "/path/to/another_open_file.txt" then return 2 end return -1 -- File not open end) _G.vim.api.nvim_buf_get_option = spy.new(function(bufnr, option_name) if option_name == "modified" then if bufnr == 1 then return false end -- open_file.lua is clean if bufnr == 2 then return true end -- another_open_file.txt is dirty end return nil -- Default for other options or unknown bufnr end) _G.vim.api.nvim_buf_get_name = spy.new(function(bufnr) if bufnr == 1 then return "/path/to/open_file.lua" end if bufnr == 2 then return "/path/to/another_open_file.txt" end return "" end) end) after_each(function() package.loaded["claudecode.tools.check_document_dirty"] = nil _G.vim.fn.bufnr = nil _G.vim.api.nvim_buf_get_option = nil _G.vim.api.nvim_buf_get_name = nil _G.vim.json.encode = nil end) it("should error if filePath parameter is missing", function() local success, err = pcall(check_document_dirty_handler, {}) expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32602) assert_contains(err.data, "Missing filePath parameter") end) it("should return success=false if file is not open in editor", function() local params = { filePath = "/path/to/non_open_file.py" } local success, result = pcall(check_document_dirty_handler, params) expect(success).to_be_true() -- No longer throws error, returns success=false expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_false() expect(parsed_result.message).to_be("Document not open: /path/to/non_open_file.py") assert.spy(_G.vim.fn.bufnr).was_called_with("/path/to/non_open_file.py") end) it("should return isDirty=false for a clean open file", function() local params = { filePath = "/path/to/open_file.lua" } local success, result = pcall(check_document_dirty_handler, params) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_true() expect(parsed_result.isDirty).to_be_false() expect(parsed_result.isUntitled).to_be_false() expect(parsed_result.filePath).to_be("/path/to/open_file.lua") assert.spy(_G.vim.fn.bufnr).was_called_with("/path/to/open_file.lua") assert.spy(_G.vim.api.nvim_buf_get_option).was_called_with(1, "modified") end) it("should return isDirty=true for a dirty open file", function() local params = { filePath = "/path/to/another_open_file.txt" } local success, result = pcall(check_document_dirty_handler, params) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_true() expect(parsed_result.isDirty).to_be_true() expect(parsed_result.isUntitled).to_be_false() expect(parsed_result.filePath).to_be("/path/to/another_open_file.txt") assert.spy(_G.vim.fn.bufnr).was_called_with("/path/to/another_open_file.txt") assert.spy(_G.vim.api.nvim_buf_get_option).was_called_with(2, "modified") end) end) ================================================ FILE: tests/unit/tools/close_all_diff_tabs_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: close_all_diff_tabs", function() local close_all_diff_tabs_handler before_each(function() package.loaded["claudecode.tools.close_all_diff_tabs"] = nil close_all_diff_tabs_handler = require("claudecode.tools.close_all_diff_tabs").handler _G.vim = _G.vim or {} _G.vim.api = _G.vim.api or {} _G.vim.fn = _G.vim.fn or {} -- Default mocks _G.vim.api.nvim_list_wins = spy.new(function() return {} end) _G.vim.api.nvim_win_get_buf = spy.new(function() return 1 end) _G.vim.api.nvim_buf_get_option = spy.new(function() return "" end) _G.vim.api.nvim_win_get_option = spy.new(function() return false end) _G.vim.api.nvim_buf_get_name = spy.new(function() return "" end) _G.vim.api.nvim_list_bufs = spy.new(function() return {} end) _G.vim.api.nvim_buf_is_loaded = spy.new(function() return false end) _G.vim.api.nvim_win_is_valid = spy.new(function() return true end) _G.vim.api.nvim_win_close = spy.new(function() return true end) _G.vim.api.nvim_buf_delete = spy.new(function() return true end) _G.vim.fn.win_findbuf = spy.new(function() return {} end) end) after_each(function() package.loaded["claudecode.tools.close_all_diff_tabs"] = nil -- Clear all mocks _G.vim.api.nvim_list_wins = nil _G.vim.api.nvim_win_get_buf = nil _G.vim.api.nvim_buf_get_option = nil _G.vim.api.nvim_win_get_option = nil _G.vim.api.nvim_buf_get_name = nil _G.vim.api.nvim_list_bufs = nil _G.vim.api.nvim_buf_is_loaded = nil _G.vim.api.nvim_win_is_valid = nil _G.vim.api.nvim_win_close = nil _G.vim.api.nvim_buf_delete = nil _G.vim.fn.win_findbuf = nil end) it("should return CLOSED_0_DIFF_TABS when no diff tabs found", function() local success, result = pcall(close_all_diff_tabs_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") expect(result.content[1].text).to_be("CLOSED_0_DIFF_TABS") end) it("should close windows in diff mode", function() _G.vim.api.nvim_list_wins = spy.new(function() return { 1, 2 } end) _G.vim.api.nvim_win_get_option = spy.new(function(win, opt) if opt == "diff" then return win == 1 -- Only window 1 is in diff mode end return false end) local success, result = pcall(close_all_diff_tabs_handler, {}) expect(success).to_be_true() expect(result.content[1].text).to_be("CLOSED_1_DIFF_TABS") assert.spy(_G.vim.api.nvim_win_close).was_called_with(1, false) end) it("should close diff-related buffers", function() _G.vim.api.nvim_list_bufs = spy.new(function() return { 1, 2 } end) _G.vim.api.nvim_buf_is_loaded = spy.new(function() return true end) _G.vim.api.nvim_buf_get_name = spy.new(function(buf) if buf == 1 then return "/path/to/file.diff" end if buf == 2 then return "/path/to/normal.txt" end return "" end) _G.vim.fn.win_findbuf = spy.new(function() return {} -- No windows for these buffers end) local success, result = pcall(close_all_diff_tabs_handler, {}) expect(success).to_be_true() expect(result.content[1].text).to_be("CLOSED_1_DIFF_TABS") assert.spy(_G.vim.api.nvim_buf_delete).was_called_with(1, { force = true }) end) end) ================================================ FILE: tests/unit/tools/get_current_selection_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: get_current_selection", function() local get_current_selection_handler local mock_selection_module before_each(function() -- Mock the selection module mock_selection_module = { get_latest_selection = spy.new(function() -- Default behavior: no selection return nil end), } package.loaded["claudecode.selection"] = mock_selection_module -- Reset and require the module under test package.loaded["claudecode.tools.get_current_selection"] = nil get_current_selection_handler = require("claudecode.tools.get_current_selection").handler -- Mock vim.api and vim.json functions that might be called by the fallback if no selection _G.vim = _G.vim or {} _G.vim.api = _G.vim.api or {} _G.vim.json = _G.vim.json or {} _G.vim.api.nvim_get_current_buf = spy.new(function() return 1 end) _G.vim.api.nvim_buf_get_name = spy.new(function(bufnr) if bufnr == 1 then return "/current/file.lua" end return "unknown_buffer" end) _G.vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) end) after_each(function() package.loaded["claudecode.selection"] = nil package.loaded["claudecode.tools.get_current_selection"] = nil _G.vim.api.nvim_get_current_buf = nil _G.vim.api.nvim_buf_get_name = nil _G.vim.json.encode = nil end) it("should return an empty selection structure if no selection is available", function() mock_selection_module.get_latest_selection = spy.new(function() return nil end) local success, result = pcall(get_current_selection_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_true() -- New success field expect(parsed_result.text).to_be("") expect(parsed_result.filePath).to_be("/current/file.lua") expect(parsed_result.selection.isEmpty).to_be_true() expect(parsed_result.selection.start.line).to_be(0) -- Default empty selection expect(parsed_result.selection.start.character).to_be(0) assert.spy(mock_selection_module.get_latest_selection).was_called() end) it("should return the selection data from claudecode.selection if available", function() local mock_sel_data = { text = "selected text", filePath = "/path/to/file.lua", fileUrl = "file:///path/to/file.lua", selection = { start = { line = 10, character = 4 }, ["end"] = { line = 10, character = 17 }, isEmpty = false, }, } mock_selection_module.get_latest_selection = spy.new(function() return mock_sel_data end) local success, result = pcall(get_current_selection_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) -- Should return the selection data with success field added local expected_result = vim.tbl_extend("force", mock_sel_data, { success = true }) assert.are.same(expected_result, parsed_result) assert.spy(mock_selection_module.get_latest_selection).was_called() end) it("should return error format when no active editor is found", function() mock_selection_module.get_latest_selection = spy.new(function() return nil end) -- Mock empty buffer name to simulate no active editor _G.vim.api.nvim_buf_get_name = spy.new(function() return "" end) local success, result = pcall(get_current_selection_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_false() expect(parsed_result.message).to_be("No active editor found") -- Should not have other fields when success is false expect(parsed_result.text).to_be_nil() expect(parsed_result.filePath).to_be_nil() expect(parsed_result.selection).to_be_nil() end) it("should handle pcall failure when requiring selection module", function() -- Simulate require failing package.loaded["claudecode.selection"] = nil -- Ensure it's not cached local original_require = _G.require _G.require = function(mod_name) if mod_name == "claudecode.selection" then error("Simulated require failure for claudecode.selection") end return original_require(mod_name) end local success, err = pcall(get_current_selection_handler, {}) _G.require = original_require -- Restore original require expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32000) assert_contains(err.message, "Internal server error") assert_contains(err.data, "Failed to load selection module") end) end) ================================================ FILE: tests/unit/tools/get_diagnostics_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: get_diagnostics", function() local get_diagnostics_handler before_each(function() package.loaded["claudecode.tools.get_diagnostics"] = nil package.loaded["claudecode.logger"] = nil -- Mock the logger module package.loaded["claudecode.logger"] = { debug = function() end, error = function() end, info = function() end, warn = function() end, } get_diagnostics_handler = require("claudecode.tools.get_diagnostics").handler _G.vim = _G.vim or {} _G.vim.lsp = _G.vim.lsp or {} -- Ensure vim.lsp exists for the check _G.vim.diagnostic = _G.vim.diagnostic or {} _G.vim.api = _G.vim.api or {} _G.vim.fn = _G.vim.fn or {} -- Default mocks _G.vim.diagnostic.get = spy.new(function() return {} end) -- Default to no diagnostics _G.vim.api.nvim_buf_get_name = spy.new(function(bufnr) return "/path/to/file_for_buf_" .. tostring(bufnr) .. ".lua" end) _G.vim.json.encode = spy.new(function(obj) return vim.inspect(obj) -- Use vim.inspect as a simple serialization end) _G.vim.fn.bufnr = spy.new(function(filepath) -- Mock buffer lookup if filepath == "/test/file.lua" then return 1 end return -1 -- File not open end) _G.vim.uri_to_fname = spy.new(function(uri) -- Realistic mock that matches vim.uri_to_fname behavior if uri:sub(1, 7) == "file://" then return uri:sub(8) end -- Real vim.uri_to_fname throws an error for URIs without proper scheme error("URI must contain a scheme: " .. uri) end) end) after_each(function() package.loaded["claudecode.tools.get_diagnostics"] = nil package.loaded["claudecode.logger"] = nil _G.vim.diagnostic.get = nil _G.vim.api.nvim_buf_get_name = nil _G.vim.json.encode = nil _G.vim.fn.bufnr = nil _G.vim.uri_to_fname = nil -- Note: We don't nullify _G.vim.lsp or _G.vim.diagnostic entirely -- as they are checked for existence. end) it("should return an empty list if no diagnostics are found", function() local success, result = pcall(get_diagnostics_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(#result.content).to_be(0) assert.spy(_G.vim.diagnostic.get).was_called_with(nil) end) it("should return formatted diagnostics if available", function() local mock_diagnostics = { { bufnr = 1, lnum = 10, col = 5, severity = 1, message = "Error message 1", source = "linter1" }, { bufnr = 2, lnum = 20, col = 15, severity = 2, message = "Warning message 2", source = "linter2" }, } _G.vim.diagnostic.get = spy.new(function() return mock_diagnostics end) local success, result = pcall(get_diagnostics_handler, {}) expect(success).to_be_true() expect(result.content).to_be_table() expect(#result.content).to_be(2) -- Check that results are MCP content items expect(result.content[1].type).to_be("text") expect(result.content[2].type).to_be("text") -- Verify JSON encoding was called with correct structure assert.spy(_G.vim.json.encode).was_called(2) -- Check the first diagnostic was encoded with 1-indexed values local first_call_args = _G.vim.json.encode.calls[1].vals[1] expect(first_call_args.filePath).to_be("/path/to/file_for_buf_1.lua") expect(first_call_args.line).to_be(11) -- 10 + 1 for 1-indexing expect(first_call_args.character).to_be(6) -- 5 + 1 for 1-indexing expect(first_call_args.severity).to_be(1) expect(first_call_args.message).to_be("Error message 1") expect(first_call_args.source).to_be("linter1") assert.spy(_G.vim.api.nvim_buf_get_name).was_called_with(1) assert.spy(_G.vim.api.nvim_buf_get_name).was_called_with(2) end) it("should filter out diagnostics with no file path", function() local mock_diagnostics = { { bufnr = 1, lnum = 10, col = 5, severity = 1, message = "Error message 1", source = "linter1" }, { bufnr = 99, lnum = 20, col = 15, severity = 2, message = "Warning message 2", source = "linter2" }, -- This one will have no path } _G.vim.diagnostic.get = spy.new(function() return mock_diagnostics end) _G.vim.api.nvim_buf_get_name = spy.new(function(bufnr) if bufnr == 1 then return "/path/to/file1.lua" end if bufnr == 99 then return "" end -- No path for bufnr 99 return "other.lua" end) local success, result = pcall(get_diagnostics_handler, {}) expect(success).to_be_true() expect(#result.content).to_be(1) -- Verify only the diagnostic with a file path was included assert.spy(_G.vim.json.encode).was_called(1) local encoded_args = _G.vim.json.encode.calls[1].vals[1] expect(encoded_args.filePath).to_be("/path/to/file1.lua") end) it("should error if vim.diagnostic.get is not available", function() _G.vim.diagnostic.get = nil local success, err = pcall(get_diagnostics_handler, {}) expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32000) assert_contains(err.message, "Feature unavailable") assert_contains(err.data, "Diagnostics not available in this editor version/configuration.") end) it("should error if vim.diagnostic is not available", function() local old_diagnostic = _G.vim.diagnostic _G.vim.diagnostic = nil local success, err = pcall(get_diagnostics_handler, {}) _G.vim.diagnostic = old_diagnostic -- Restore expect(success).to_be_false() expect(err.code).to_be(-32000) end) it("should error if vim.lsp is not available for the check (though diagnostic is primary)", function() local old_lsp = _G.vim.lsp _G.vim.lsp = nil local success, err = pcall(get_diagnostics_handler, {}) _G.vim.lsp = old_lsp -- Restore expect(success).to_be_false() expect(err.code).to_be(-32000) end) it("should filter diagnostics by URI when provided", function() local mock_diagnostics = { { bufnr = 1, lnum = 10, col = 5, severity = 1, message = "Error in file1", source = "linter1" }, } _G.vim.diagnostic.get = spy.new(function(bufnr) if bufnr == 1 then return mock_diagnostics end return {} end) _G.vim.api.nvim_buf_get_name = spy.new(function(bufnr) if bufnr == 1 then return "/test/file.lua" end return "" end) local success, result = pcall(get_diagnostics_handler, { uri = "file:///test/file.lua" }) expect(success).to_be_true() expect(#result.content).to_be(1) -- Should have used vim.uri_to_fname to convert URI to file path assert.spy(_G.vim.uri_to_fname).was_called_with("file:///test/file.lua") assert.spy(_G.vim.diagnostic.get).was_called_with(1) assert.spy(_G.vim.fn.bufnr).was_called_with("/test/file.lua") end) it("should error for URI of unopened file", function() _G.vim.fn.bufnr = spy.new(function() return -1 -- File not open end) local success, err = pcall(get_diagnostics_handler, { uri = "file:///unknown/file.lua" }) expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32001) expect(err.message).to_be("File not open") assert_contains(err.data, "File must be open to retrieve diagnostics: /unknown/file.lua") -- Should have used vim.uri_to_fname and checked for buffer but not called vim.diagnostic.get assert.spy(_G.vim.uri_to_fname).was_called_with("file:///unknown/file.lua") assert.spy(_G.vim.fn.bufnr).was_called_with("/unknown/file.lua") assert.spy(_G.vim.diagnostic.get).was_not_called() end) end) ================================================ FILE: tests/unit/tools/get_latest_selection_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: get_latest_selection", function() local get_latest_selection_handler local mock_selection_module before_each(function() -- Mock the selection module mock_selection_module = { get_latest_selection = spy.new(function() -- Default behavior: no selection return nil end), } package.loaded["claudecode.selection"] = mock_selection_module -- Reset and require the module under test package.loaded["claudecode.tools.get_latest_selection"] = nil get_latest_selection_handler = require("claudecode.tools.get_latest_selection").handler -- Mock vim.json functions _G.vim = _G.vim or {} _G.vim.json = _G.vim.json or {} _G.vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) end) after_each(function() package.loaded["claudecode.selection"] = nil package.loaded["claudecode.tools.get_latest_selection"] = nil _G.vim.json.encode = nil end) it("should return success=false if no selection is available", function() mock_selection_module.get_latest_selection = spy.new(function() return nil end) local success, result = pcall(get_latest_selection_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_false() expect(parsed_result.message).to_be("No selection available") assert.spy(mock_selection_module.get_latest_selection).was_called() end) it("should return the selection data if available", function() local mock_sel_data = { text = "selected text", filePath = "/path/to/file.lua", fileUrl = "file:///path/to/file.lua", selection = { start = { line = 10, character = 4 }, ["end"] = { line = 10, character = 17 }, isEmpty = false, }, } mock_selection_module.get_latest_selection = spy.new(function() return mock_sel_data end) local success, result = pcall(get_latest_selection_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) assert.are.same(mock_sel_data, parsed_result) assert.spy(mock_selection_module.get_latest_selection).was_called() end) it("should handle pcall failure when requiring selection module", function() -- Simulate require failing package.loaded["claudecode.selection"] = nil local original_require = _G.require _G.require = function(mod_name) if mod_name == "claudecode.selection" then error("Simulated require failure for claudecode.selection") end return original_require(mod_name) end local success, err = pcall(get_latest_selection_handler, {}) _G.require = original_require -- Restore original require expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32000) expect(err.message).to_be("Internal server error") expect(err.data).to_be("Failed to load selection module") end) end) ================================================ FILE: tests/unit/tools/get_open_editors_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: get_open_editors", function() local get_open_editors_handler before_each(function() package.loaded["claudecode.tools.get_open_editors"] = nil get_open_editors_handler = require("claudecode.tools.get_open_editors").handler _G.vim = _G.vim or {} _G.vim.api = _G.vim.api or {} _G.vim.fn = _G.vim.fn or {} _G.vim.json = _G.vim.json or {} -- Mock vim.json.encode _G.vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) -- Default mocks _G.vim.api.nvim_list_bufs = spy.new(function() return {} end) _G.vim.api.nvim_buf_is_loaded = spy.new(function() return false end) _G.vim.fn.buflisted = spy.new(function() return 0 end) _G.vim.api.nvim_buf_get_name = spy.new(function() return "" end) _G.vim.api.nvim_buf_get_option = spy.new(function() return false end) _G.vim.api.nvim_get_current_buf = spy.new(function() return 1 end) _G.vim.api.nvim_get_current_tabpage = spy.new(function() return 1 end) _G.vim.api.nvim_buf_line_count = spy.new(function() return 10 end) _G.vim.fn.fnamemodify = spy.new(function(path, modifier) if modifier == ":t" then return path:match("[^/]+$") or path -- Extract filename end return path end) end) after_each(function() package.loaded["claudecode.tools.get_open_editors"] = nil -- Clear mocks _G.vim.api.nvim_list_bufs = nil _G.vim.api.nvim_buf_is_loaded = nil _G.vim.fn.buflisted = nil _G.vim.api.nvim_buf_get_name = nil _G.vim.api.nvim_buf_get_option = nil _G.vim.api.nvim_get_current_buf = nil _G.vim.api.nvim_get_current_tabpage = nil _G.vim.api.nvim_buf_line_count = nil _G.vim.fn.fnamemodify = nil _G.vim.json.encode = nil end) it("should return an empty list if no listed buffers are found", function() local success, result = pcall(get_open_editors_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.tabs).to_be_table() expect(#parsed_result.tabs).to_be(0) end) it("should return a list of open and listed editors", function() -- Ensure fresh api and fn tables for this specific test's mocks _G.vim.api = {} -- Keep api mock specific to this test's needs _G.vim.fn = { ---@type vim_fn_table -- Add common stubs, buflisted will be spied below mode = function() return "n" end, delete = function(_, _) return 0 end, filereadable = function(_) return 1 end, fnamemodify = function(fname, _) return fname end, expand = function(s, _) return s end, getcwd = function() return "/mock/cwd" end, mkdir = function(_, _, _) return 1 end, buflisted = function(_) return 1 end, -- Stub for type, will be spied -- buflisted will be spied bufname = function(_) return "mockbuffer" end, bufnr = function(_) return 1 end, win_getid = function() return 1 end, win_gotoid = function(_) return true end, line = function(_) return 1 end, col = function(_) return 1 end, virtcol = function(_) return 1 end, getpos = function(_) return { 0, 1, 1, 0 } end, setpos = function(_, _) return true end, tempname = function() return "/tmp/mocktemp" end, globpath = function(_, _) return "" end, stdpath = function(_) return "/mock/stdpath" end, json_encode = function(_) return "{}" end, json_decode = function(_) return {} end, termopen = function(_, _) return 0 end, } _G.vim.api.nvim_list_bufs = spy.new(function() return { 1, 2, 3 } end) _G.vim.api.nvim_buf_is_loaded = spy.new(function(bufnr) return bufnr == 1 or bufnr == 2 -- Buffer 3 is not loaded end) _G.vim.fn.buflisted = spy.new(function(bufnr) -- The handler checks `vim.fn.buflisted(bufnr) == 1` if bufnr == 1 or bufnr == 2 then return 1 end return 0 -- Buffer 3 not listed end) _G.vim.api.nvim_buf_get_name = spy.new(function(bufnr) if bufnr == 1 then return "/path/to/file1.lua" end if bufnr == 2 then return "/path/to/file2.txt" end return "" end) _G.vim.api.nvim_buf_get_option = spy.new(function(bufnr, opt_name) if opt_name == "modified" then return bufnr == 2 -- file2.txt is dirty elseif opt_name == "filetype" then if bufnr == 1 then return "lua" elseif bufnr == 2 then return "text" end end return false end) _G.vim.api.nvim_get_current_buf = spy.new(function() return 1 -- Buffer 1 is active end) _G.vim.api.nvim_get_current_tabpage = spy.new(function() return 1 end) _G.vim.api.nvim_buf_line_count = spy.new(function(bufnr) if bufnr == 1 then return 100 elseif bufnr == 2 then return 50 end return 0 end) _G.vim.fn.fnamemodify = spy.new(function(path, modifier) if modifier == ":t" then return path:match("[^/]+$") or path -- Extract filename end return path end) _G.vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) local success, result = pcall(get_open_editors_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.tabs).to_be_table() expect(#parsed_result.tabs).to_be(2) expect(parsed_result.tabs[1].uri).to_be("file:///path/to/file1.lua") expect(parsed_result.tabs[1].isActive).to_be_true() expect(parsed_result.tabs[1].label).to_be("file1.lua") expect(parsed_result.tabs[1].languageId).to_be("lua") expect(parsed_result.tabs[1].isDirty).to_be_false() expect(parsed_result.tabs[2].uri).to_be("file:///path/to/file2.txt") expect(parsed_result.tabs[2].isActive).to_be_false() expect(parsed_result.tabs[2].label).to_be("file2.txt") expect(parsed_result.tabs[2].languageId).to_be("text") expect(parsed_result.tabs[2].isDirty).to_be_true() end) it("should include VS Code-compatible fields for each tab", function() -- Mock selection module to prevent errors package.loaded["claudecode.selection"] = { get_latest_selection = function() return nil end, } -- Mock all necessary API calls _G.vim.api.nvim_list_bufs = spy.new(function() return { 1 } end) _G.vim.api.nvim_buf_is_loaded = spy.new(function() return true end) _G.vim.fn.buflisted = spy.new(function() return 1 end) _G.vim.api.nvim_buf_get_name = spy.new(function() return "/path/to/test.lua" end) _G.vim.api.nvim_buf_get_option = spy.new(function(bufnr, opt_name) if opt_name == "modified" then return false elseif opt_name == "filetype" then return "lua" end return nil end) _G.vim.api.nvim_get_current_buf = spy.new(function() return 1 end) _G.vim.api.nvim_get_current_tabpage = spy.new(function() return 1 end) _G.vim.api.nvim_buf_line_count = spy.new(function() return 42 end) _G.vim.fn.fnamemodify = spy.new(function(path, modifier) if modifier == ":t" then return "test.lua" end return path end) local success, result = pcall(get_open_editors_handler, {}) expect(success).to_be_true() local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.tabs).to_be_table() expect(#parsed_result.tabs).to_be(1) local tab = parsed_result.tabs[1] -- Check all VS Code-compatible fields expect(tab.uri).to_be("file:///path/to/test.lua") expect(tab.isActive).to_be_true() expect(tab.isPinned).to_be_false() expect(tab.isPreview).to_be_false() expect(tab.isDirty).to_be_false() expect(tab.label).to_be("test.lua") expect(tab.groupIndex).to_be(0) -- 0-based expect(tab.viewColumn).to_be(1) -- 1-based expect(tab.isGroupActive).to_be_true() expect(tab.fileName).to_be("/path/to/test.lua") expect(tab.languageId).to_be("lua") expect(tab.lineCount).to_be(42) expect(tab.isUntitled).to_be_false() -- Clean up selection module mock package.loaded["claudecode.selection"] = nil end) it("should filter out buffers that are not loaded", function() _G.vim.api.nvim_list_bufs = spy.new(function() return { 1 } end) _G.vim.api.nvim_buf_is_loaded = spy.new(function() return false end) -- Not loaded _G.vim.fn.buflisted = spy.new(function() return 1 end) _G.vim.api.nvim_buf_get_name = spy.new(function() return "/path/to/file1.lua" end) local success, result = pcall(get_open_editors_handler, {}) expect(success).to_be_true() expect(result.content).to_be_table() local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(#parsed_result.tabs).to_be(0) end) it("should filter out buffers that are not listed", function() _G.vim.api.nvim_list_bufs = spy.new(function() return { 1 } end) _G.vim.api.nvim_buf_is_loaded = spy.new(function() return true end) _G.vim.fn.buflisted = spy.new(function() return 0 end) -- Not listed _G.vim.api.nvim_buf_get_name = spy.new(function() return "/path/to/file1.lua" end) local success, result = pcall(get_open_editors_handler, {}) expect(success).to_be_true() expect(result.content).to_be_table() local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(#parsed_result.tabs).to_be(0) end) it("should filter out buffers with no file path", function() _G.vim.api.nvim_list_bufs = spy.new(function() return { 1 } end) _G.vim.api.nvim_buf_is_loaded = spy.new(function() return true end) _G.vim.fn.buflisted = spy.new(function() return 1 end) _G.vim.api.nvim_buf_get_name = spy.new(function() return "" end) -- Empty path local success, result = pcall(get_open_editors_handler, {}) expect(success).to_be_true() expect(result.content).to_be_table() local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(#parsed_result.tabs).to_be(0) end) end) ================================================ FILE: tests/unit/tools/get_workspace_folders_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: get_workspace_folders", function() local get_workspace_folders_handler before_each(function() package.loaded["claudecode.tools.get_workspace_folders"] = nil get_workspace_folders_handler = require("claudecode.tools.get_workspace_folders").handler _G.vim = _G.vim or {} _G.vim.fn = _G.vim.fn or {} _G.vim.json = _G.vim.json or {} -- Mock vim.json.encode _G.vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) -- Default mocks _G.vim.fn.getcwd = spy.new(function() return "/mock/project/root" end) _G.vim.fn.fnamemodify = spy.new(function(path, mod) if mod == ":t" then local parts = {} for part in string.gmatch(path, "[^/]+") do table.insert(parts, part) end return parts[#parts] or "" end return path end) end) after_each(function() package.loaded["claudecode.tools.get_workspace_folders"] = nil _G.vim.fn.getcwd = nil _G.vim.fn.fnamemodify = nil _G.vim.json.encode = nil end) it("should return the current working directory as the only workspace folder", function() local success, result = pcall(get_workspace_folders_handler, {}) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_true() expect(parsed_result.folders).to_be_table() expect(#parsed_result.folders).to_be(1) expect(parsed_result.rootPath).to_be("/mock/project/root") local folder = parsed_result.folders[1] expect(folder.name).to_be("root") expect(folder.uri).to_be("file:///mock/project/root") expect(folder.path).to_be("/mock/project/root") assert.spy(_G.vim.fn.getcwd).was_called() assert.spy(_G.vim.fn.fnamemodify).was_called_with("/mock/project/root", ":t") end) it("should handle different CWD paths correctly", function() _G.vim.fn.getcwd = spy.new(function() return "/another/path/project_name" end) local success, result = pcall(get_workspace_folders_handler, {}) expect(success).to_be_true() expect(result.content).to_be_table() local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(#parsed_result.folders).to_be(1) local folder = parsed_result.folders[1] expect(folder.name).to_be("project_name") expect(folder.uri).to_be("file:///another/path/project_name") expect(folder.path).to_be("/another/path/project_name") end) -- TODO: Add tests when LSP workspace folder integration is implemented in the tool. -- This would involve mocking vim.lsp.get_clients() and its return structure. end) ================================================ FILE: tests/unit/tools/open_diff_mcp_spec.lua ================================================ --- Tests for MCP-compliant openDiff tool require("tests.busted_setup") local open_diff_tool = require("claudecode.tools.open_diff") describe("openDiff tool MCP compliance", function() local test_old_file = "" local test_new_file = "" local test_content_old = "line 1\nline 2\noriginal content" local test_content_new = "line 1\nline 2\nnew content\nextra line" local test_tab_name = "test_diff_tab" before_each(function() -- Use predictable test file paths for better CI compatibility test_old_file = "/test/old_file.txt" test_new_file = "/test/new_file.txt" -- Mock io.open to return test content without actual file system access local original_io_open = io.open rawset(io, "open", function(filename, mode) if filename == test_old_file and mode == "r" then return { read = function(self, format) if format == "*all" then return test_content_old end return nil end, close = function() end, } end -- Fall back to original for other files return original_io_open(filename, mode) end) -- Store original for cleanup _G._original_io_open = original_io_open end) after_each(function() -- Restore original io.open if _G._original_io_open then rawset(io, "open", _G._original_io_open) _G._original_io_open = nil end -- Clean up any active diffs require("claudecode.diff")._cleanup_all_active_diffs("test_cleanup") end) describe("tool schema", function() it("should have correct tool definition", function() assert.equal("openDiff", open_diff_tool.name) assert.is_table(open_diff_tool.schema) assert.is_function(open_diff_tool.handler) end) it("should have required parameters in schema", function() local required = open_diff_tool.schema.inputSchema.required assert.is_table(required) assert_contains(required, "old_file_path") assert_contains(required, "new_file_path") assert_contains(required, "new_file_contents") assert_contains(required, "tab_name") end) end) describe("parameter validation", function() it("should error on missing required parameters", function() local params = { old_file_path = test_old_file, new_file_path = test_new_file, new_file_contents = test_content_new, -- missing tab_name } local co = coroutine.create(function() open_diff_tool.handler(params) end) local success, err = coroutine.resume(co) assert.is_false(success) assert.is_table(err) assert.equal(-32602, err.code) assert.matches("Missing required parameter: tab_name", err.data) end) it("should validate all required parameters", function() local required_params = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" } for _, param_name in ipairs(required_params) do local params = { old_file_path = test_old_file, new_file_path = test_new_file, new_file_contents = test_content_new, tab_name = test_tab_name, } params[param_name] = nil -- Remove the parameter local co = coroutine.create(function() open_diff_tool.handler(params) end) local success, err = coroutine.resume(co) assert.is_false(success, "Should fail when missing " .. param_name) assert.is_table(err) assert.equal(-32602, err.code) assert.matches("Missing required parameter: " .. param_name, err.data) end end) end) describe("coroutine context requirement", function() it("should error when not in coroutine context", function() local params = { old_file_path = test_old_file, new_file_path = test_new_file, new_file_contents = test_content_new, tab_name = test_tab_name, } local success, err = pcall(open_diff_tool.handler, params) assert.is_false(success) assert.is_table(err) assert.equal(-32000, err.code) assert_contains(err.data, "openDiff must run in coroutine context") end) end) describe("MCP-compliant responses", function() it("should return MCP format on file save", function() local params = { old_file_path = test_old_file, new_file_path = test_new_file, new_file_contents = test_content_new, tab_name = test_tab_name, } local result = nil local co = coroutine.create(function() result = open_diff_tool.handler(params) end) -- Start the coroutine local success, err = coroutine.resume(co) assert.is_true(success, "Tool should start successfully: " .. tostring(err)) assert.equal("suspended", coroutine.status(co), "Should be suspended waiting for user action") -- Simulate file save vim.schedule(function() require("claudecode.diff")._resolve_diff_as_saved(test_tab_name, 1) end) -- Wait for resolution vim.wait(100, function() -- Reduced from 1000ms to 100ms return coroutine.status(co) == "dead" end) assert.is_not_nil(result) assert.is_table(result.content) assert.equal(2, #result.content) assert.equal("FILE_SAVED", result.content[1].text) assert.equal("text", result.content[1].type) assert.is_string(result.content[2].text) assert.equal("text", result.content[2].type) end) it("should return MCP format on diff rejection", function() local params = { old_file_path = test_old_file, new_file_path = test_new_file, new_file_contents = test_content_new, tab_name = test_tab_name, } local result = nil local co = coroutine.create(function() result = open_diff_tool.handler(params) end) -- Start the coroutine local success, err = coroutine.resume(co) assert.is_true(success, "Tool should start successfully: " .. tostring(err)) assert.equal("suspended", coroutine.status(co), "Should be suspended waiting for user action") -- Simulate diff rejection vim.schedule(function() require("claudecode.diff")._resolve_diff_as_rejected(test_tab_name) end) -- Wait for resolution vim.wait(100, function() -- Reduced from 1000ms to 100ms return coroutine.status(co) == "dead" end) assert.is_not_nil(result) assert.is_table(result.content) assert.equal(2, #result.content) assert.equal("DIFF_REJECTED", result.content[1].text) assert.equal("text", result.content[1].type) assert.equal(test_tab_name, result.content[2].text) assert.equal("text", result.content[2].type) end) end) describe("error handling", function() it("should handle new files successfully", function() local params = { old_file_path = "/tmp/non_existent_file.txt", new_file_path = test_new_file, new_file_contents = test_content_new, tab_name = test_tab_name, } -- Set up mock resolution to avoid hanging _G.claude_deferred_responses = { [tostring(coroutine.running())] = function(result) -- Mock resolution end, } local co = coroutine.create(function() open_diff_tool.handler(params) end) local success = coroutine.resume(co) assert.is_true(success, "Should handle new file scenario successfully") -- The coroutine should yield (waiting for user action) assert.equal("suspended", coroutine.status(co)) end) it("should handle diff module loading errors", function() -- Mock require to fail local original_require = require _G.require = function(module) if module == "claudecode.diff" then error("Mock diff module load failure") end return original_require(module) end local params = { old_file_path = test_old_file, new_file_path = test_new_file, new_file_contents = test_content_new, tab_name = test_tab_name, } local co = coroutine.create(function() open_diff_tool.handler(params) end) local success, err = coroutine.resume(co) assert.is_false(success) assert.is_table(err) assert.equal(-32000, err.code) assert.matches("Failed to load diff module", err.data) -- Restore original require _G.require = original_require end) it("should propagate structured errors from diff module", function() -- Mock diff module to throw structured error local original_require = require _G.require = function(module) if module == "claudecode.diff" then return { open_diff_blocking = function() error({ code = -32001, message = "Custom diff error", data = "Custom error data", }) end, } end return original_require(module) end local params = { old_file_path = test_old_file, new_file_path = test_new_file, new_file_contents = test_content_new, tab_name = test_tab_name, } local co = coroutine.create(function() open_diff_tool.handler(params) end) local success, err = coroutine.resume(co) assert.is_false(success) assert.is_table(err) assert.equal(-32001, err.code) assert.equal("Custom diff error", err.message) assert.equal("Custom error data", err.data) -- Restore original require _G.require = original_require end) end) end) ================================================ FILE: tests/unit/tools/open_file_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: open_file", function() local open_file_handler before_each(function() -- Reset mocks and require the module under test package.loaded["claudecode.tools.open_file"] = nil open_file_handler = require("claudecode.tools.open_file").handler -- Mock Neovim functions used by the handler _G.vim = _G.vim or {} _G.vim.fn = _G.vim.fn or {} _G.vim.api = _G.vim.api or {} _G.vim.cmd_history = {} -- Store cmd history for assertions _G.vim.fn.expand = spy.new(function(path) return path -- Simple pass-through for testing end) _G.vim.fn.filereadable = spy.new(function(path) if path == "non_readable_file.txt" then return 0 end return 1 -- Assume readable by default for other paths end) _G.vim.fn.fnameescape = spy.new(function(path) return path -- Simple pass-through end) _G.vim.cmd = spy.new(function(command) table.insert(_G.vim.cmd_history, command) end) -- Mock vim.json.encode _G.vim.json = _G.vim.json or {} _G.vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) -- Mock window-related APIs _G.vim.api.nvim_list_wins = spy.new(function() return { 1000 } -- Return a single window end) _G.vim.api.nvim_win_get_buf = spy.new(function(win) return 1 -- Mock buffer ID end) _G.vim.api.nvim_buf_get_option = spy.new(function(buf, option) return "" -- Return empty string for all options end) _G.vim.api.nvim_win_get_config = spy.new(function(win) return {} -- Return empty config (no relative positioning) end) _G.vim.api.nvim_win_call = spy.new(function(win, callback) return callback() -- Just execute the callback end) _G.vim.api.nvim_set_current_win = spy.new(function(win) -- Do nothing end) _G.vim.api.nvim_get_current_win = spy.new(function() return 1000 end) _G.vim.api.nvim_get_current_buf = spy.new(function() return 1 -- Mock current buffer ID end) _G.vim.api.nvim_buf_get_name = spy.new(function(buf) return "test.txt" -- Mock buffer name end) _G.vim.api.nvim_buf_line_count = spy.new(function(buf) return 10 -- Mock line count end) _G.vim.api.nvim_buf_set_mark = spy.new(function(buf, name, line, col, opts) -- Mock mark setting end) _G.vim.api.nvim_buf_get_lines = spy.new(function(buf, start, end_line, strict) -- Mock buffer lines for search return { "local function test()", " print('hello')", " return true", "end", } end) _G.vim.api.nvim_win_set_cursor = spy.new(function(win, pos) -- Mock cursor setting end) end) after_each(function() -- Clean up global mocks if necessary, though spy.restore() is better if using full spy.lua _G.vim.fn.expand = nil _G.vim.fn.filereadable = nil _G.vim.fn.fnameescape = nil _G.vim.cmd = nil _G.vim.cmd_history = nil end) it("should error if filePath parameter is missing", function() local success, err = pcall(open_file_handler, {}) expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32602) -- Invalid params assert_contains(err.message, "Invalid params") assert_contains(err.data, "Missing filePath parameter") end) it("should error if file is not readable", function() local params = { filePath = "non_readable_file.txt" } local success, err = pcall(open_file_handler, params) expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32000) -- File operation error assert_contains(err.message, "File operation error") assert_contains(err.data, "File not found: non_readable_file.txt") assert.spy(_G.vim.fn.expand).was_called_with("non_readable_file.txt") assert.spy(_G.vim.fn.filereadable).was_called_with("non_readable_file.txt") end) it("should call vim.cmd with edit and the escaped file path on success", function() local params = { filePath = "readable_file.txt" } local success, result = pcall(open_file_handler, params) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") expect(result.content[1].text).to_be("Opened file: readable_file.txt") assert.spy(_G.vim.fn.expand).was_called_with("readable_file.txt") assert.spy(_G.vim.fn.filereadable).was_called_with("readable_file.txt") assert.spy(_G.vim.fn.fnameescape).was_called_with("readable_file.txt") expect(#_G.vim.cmd_history).to_be(1) expect(_G.vim.cmd_history[1]).to_be("edit readable_file.txt") end) it("should handle filePath needing expansion", function() _G.vim.fn.expand = spy.new(function(path) if path == "~/.config/nvim/init.lua" then return "/Users/testuser/.config/nvim/init.lua" end return path end) local params = { filePath = "~/.config/nvim/init.lua" } local success, result = pcall(open_file_handler, params) expect(success).to_be_true() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") expect(result.content[1].text).to_be("Opened file: /Users/testuser/.config/nvim/init.lua") assert.spy(_G.vim.fn.expand).was_called_with("~/.config/nvim/init.lua") assert.spy(_G.vim.fn.filereadable).was_called_with("/Users/testuser/.config/nvim/init.lua") assert.spy(_G.vim.fn.fnameescape).was_called_with("/Users/testuser/.config/nvim/init.lua") expect(_G.vim.cmd_history[1]).to_be("edit /Users/testuser/.config/nvim/init.lua") end) it("should handle makeFrontmost=false to return detailed JSON", function() local params = { filePath = "test.txt", makeFrontmost = false } local success, result = pcall(open_file_handler, params) expect(success).to_be_true() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") -- makeFrontmost=false should return JSON-encoded detailed info local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_true() expect(parsed_result.filePath).to_be("test.txt") end) it("should handle preview mode parameter", function() local params = { filePath = "test.txt", preview = true } local success, result = pcall(open_file_handler, params) expect(success).to_be_true() expect(result.content[1].text).to_be("Opened file: test.txt") -- Preview mode affects window behavior but basic functionality should work end) it("should handle line selection parameters", function() -- Mock additional functions needed for line selection _G.vim.api.nvim_win_set_cursor = spy.new(function(win, pos) -- Mock cursor setting end) _G.vim.fn.setpos = spy.new(function(mark, pos) -- Mock position setting end) local params = { filePath = "test.txt", startLine = 5, endLine = 10 } local success, result = pcall(open_file_handler, params) expect(success).to_be_true() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") expect(result.content[1].text).to_be("Opened file and selected lines 5 to 10") end) it("should handle text pattern selection when pattern found", function() local params = { filePath = "test.txt", startText = "function", endText = "end", selectToEndOfLine = true, } local success, result = pcall(open_file_handler, params) expect(success).to_be_true() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") -- Since the mock buffer contains "function" and "end", selection should work expect(result.content[1].text).to_be('Opened file and selected text from "function" to "end"') end) it("should handle text pattern selection when pattern not found", function() -- Mock search to return 0 (not found) _G.vim.fn.search = spy.new(function(pattern) return 0 -- Pattern not found end) local params = { filePath = "test.txt", startText = "nonexistent", } local success, result = pcall(open_file_handler, params) expect(success).to_be_true() expect(result.content).to_be_table() expect(result.content[1].type).to_be("text") assert_contains(result.content[1].text, "not found") end) end) ================================================ FILE: tests/unit/tools/save_document_spec.lua ================================================ require("tests.busted_setup") -- Ensure test helpers are loaded describe("Tool: save_document", function() local save_document_handler before_each(function() -- Clear module cache first package.loaded["claudecode.tools.save_document"] = nil -- Setup mocks and spies BEFORE requiring the module _G.vim = _G.vim or {} _G.vim.fn = _G.vim.fn or {} _G.vim.api = _G.vim.api or {} _G.vim.cmd_history = {} -- To track vim.cmd calls _G.vim.fn.bufnr = spy.new(function(filePath) if filePath == "/path/to/saveable_file.lua" then return 1 end return -1 -- File not open end) _G.vim.api.nvim_buf_call = spy.new(function(bufnr, callback) if bufnr == 1 then callback() -- Execute the callback which should call vim.cmd("write") else error("nvim_buf_call called with unexpected bufnr: " .. tostring(bufnr)) end end) _G.vim.cmd = spy.new(function(command) table.insert(_G.vim.cmd_history, command) end) -- Mock vim.json.encode _G.vim.json = _G.vim.json or {} _G.vim.json.encode = spy.new(function(data, opts) return require("tests.busted_setup").json_encode(data) end) -- Now require the module, it will pick up the spied functions save_document_handler = require("claudecode.tools.save_document").handler end) after_each(function() package.loaded["claudecode.tools.save_document"] = nil _G.vim.fn.bufnr = nil _G.vim.api.nvim_buf_call = nil _G.vim.cmd = nil _G.vim.cmd_history = nil _G.vim.json.encode = nil end) it("should error if filePath parameter is missing", function() local success, err = pcall(save_document_handler, {}) expect(success).to_be_false() expect(err).to_be_table() expect(err.code).to_be(-32602) assert_contains(err.data, "Missing filePath parameter") end) it("should return success=false if file is not open in editor", function() local params = { filePath = "/path/to/non_open_file.py" } local success, result = pcall(save_document_handler, params) expect(success).to_be_true() -- No longer throws error, returns success=false expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_false() expect(parsed_result.message).to_be("Document not open: /path/to/non_open_file.py") assert.spy(_G.vim.fn.bufnr).was_called_with("/path/to/non_open_file.py") end) it("should call nvim_buf_call and vim.cmd('write') on success", function() local params = { filePath = "/path/to/saveable_file.lua" } -- Get a reference to the spy *before* calling the handler -- local nvim_buf_call_spy = _G.vim.api.nvim_buf_call -- Not needed before handler call local success, result = pcall(save_document_handler, params) expect(success).to_be_true() expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_true() expect(parsed_result.saved).to_be_true() expect(parsed_result.filePath).to_be("/path/to/saveable_file.lua") expect(parsed_result.message).to_be("Document saved successfully") assert.spy(_G.vim.fn.bufnr).was_called_with("/path/to/saveable_file.lua") -- Get the spy object for assertion using assert.spy() -- _G.vim.api.nvim_buf_call should be the spy set in before_each -- _G.vim.api.nvim_buf_call is the actual spy object from spy.new() -- It has .call_count and .calls fields directly. -- assert.spy() returns a wrapper for chained assertions, not for direct field access. local actual_nvim_buf_call_spy = _G.vim.api.nvim_buf_call -- This is the original spy -- Add a check to see what _G.vim.api.nvim_buf_call actually is at this point if type(actual_nvim_buf_call_spy) ~= "table" then print("ERROR: actual_nvim_buf_call_spy is not a table. Value: " .. tostring(actual_nvim_buf_call_spy)) end assert.is_table(actual_nvim_buf_call_spy, "Spy object _G.vim.api.nvim_buf_call should be a table") -- Check for typical spy methods/fields assert.is_function(actual_nvim_buf_call_spy.clear, "Spy should have a .clear method") assert.is_function(actual_nvim_buf_call_spy.called, "Spy should have a .called method (property-style)") assert.is_not_nil(actual_nvim_buf_call_spy.calls, "Spy should have a .calls table") -- Use Luassert's spy assertion methods assert.spy(actual_nvim_buf_call_spy).was_called(1) -- assert.spy(actual_nvim_buf_call_spy).was_called_with(1, spy.any) -- This seems to be problematic with spy.any for functions -- If was_called_with passes, we can then inspect the specific call's arguments if needed, -- but often was_called_with(..., spy.any) is sufficient for function arguments. -- For demonstration, let's keep the direct check for the callback type from the spy's internal .calls table assert.is_not_nil(actual_nvim_buf_call_spy.calls[1], "Spy's first call record (calls[1]) should not be nil") local call_args = actual_nvim_buf_call_spy.calls[1].vals -- Arguments are in .vals based on debug output assert.is_not_nil(call_args, "Spy's first call arguments (calls[1].args) should not be nil") assert.are.equal(1, call_args[1]) assert.are.equal("function", type(call_args[2])) local cmd_history_len = #_G.vim.cmd_history local first_cmd = _G.vim.cmd_history[1] assert.are.equal(1, cmd_history_len) assert.are.equal("write", first_cmd) end) it("should return success=false if nvim_buf_call fails", function() _G.vim.api.nvim_buf_call = spy.new(function(bufnr, callback) error("Simulated nvim_buf_call failure") end) local params = { filePath = "/path/to/saveable_file.lua" } local success, result = pcall(save_document_handler, params) expect(success).to_be_true() -- No longer throws error, returns success=false expect(result).to_be_table() expect(result.content).to_be_table() expect(result.content[1]).to_be_table() expect(result.content[1].type).to_be("text") local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) expect(parsed_result.success).to_be_false() assert_contains(parsed_result.message, "Failed to save file") expect(parsed_result.filePath).to_be("/path/to/saveable_file.lua") end) end) ================================================ FILE: .claude/settings.json ================================================ { "hooks": { "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh" } ] } ] } } ================================================ FILE: .claude/hooks/format.sh ================================================ #!/usr/bin/env bash # # Claude Code Hook: Format Files # Triggers after Claude edits/writes files and runs nix fmt # # Environment variables provided by Claude Code: # - CLAUDE_PROJECT_DIR: Path to the project directory # - CLAUDE_TOOL_NAME: Name of the tool that was executed # - CLAUDE_TOOL_ARGS: JSON string containing tool arguments set -euo pipefail # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Log function log() { echo -e "[$(date '+%H:%M:%S')] $1" >&2 } # Parse tool arguments to get the file path get_file_path() { # Read hook input from stdin local hook_input if [ -t 0 ]; then # No stdin input available log "DEBUG: No stdin input available" return fi hook_input=$(cat) log "DEBUG: Hook input = $hook_input" # Try to extract file_path from tool_input local file_path file_path=$(echo "$hook_input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') if [ -n "$file_path" ]; then echo "$file_path" return fi # Try extracting any file path from the input local any_file_path any_file_path=$(echo "$hook_input" | grep -o '"[^"]*\.[^"]*"' | sed 's/"//g' | head -1) if [ -n "$any_file_path" ]; then echo "$any_file_path" return fi log "DEBUG: Could not extract file path from hook input" } # Main logic main() { log "${YELLOW}Claude Code Hook: File Formatter${NC}" # Get the file path from tool arguments FILE_PATH=$(get_file_path) if [ -z "$FILE_PATH" ]; then log "${RED}Error: Could not determine file path from tool arguments${NC}" exit 1 fi log "Tool: ${CLAUDE_TOOL_NAME:-unknown}, File: $FILE_PATH" # Check if file exists if [ ! -f "$FILE_PATH" ]; then log "${RED}Error: File does not exist: $FILE_PATH${NC}" exit 1 fi log "${YELLOW}Formatting file with nix fmt...${NC}" # Change to project directory cd "${CLAUDE_PROJECT_DIR}" # Run nix fmt on the file if nix fmt "$FILE_PATH" 2>/dev/null; then log "${GREEN}✓ Successfully formatted: $FILE_PATH${NC}" exit 0 else EXIT_CODE=$? log "${RED}✗ nix fmt failed with exit code $EXIT_CODE${NC}" log "${RED}This indicates the file has formatting issues that need manual attention${NC}" # Don't fail the hook - just warn about formatting issues # This allows Claude's operation to continue while alerting about format problems log "${YELLOW}Continuing with Claude's operation, but please fix formatting issues${NC}" exit 0 fi } # Run main function main "$@" ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "[BUG] " labels: bug assignees: "" --- ## Bug Description A clear and concise description of what the bug is. ## To Reproduce Steps to reproduce the behavior: 1. Start Claude Code integration with '...' 2. Run command '....' 3. See error ## Expected Behavior A clear and concise description of what you expected to happen. ## Environment - Neovim version: [e.g. 0.9.2] - Claude Code CLI version: [e.g. 1.0.0] - OS: [e.g. macOS 14.1] - Plugin version: [e.g. 0.1.0] ## Error Messages If applicable, add error messages or logs. ``` Paste any error messages here ``` ## Additional Context Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "[FEATURE] " labels: enhancement assignees: "" --- ## Feature Description A clear and concise description of what you'd like to see added to the plugin. ## Use Case Describe the context or use case for this feature. How would it benefit you and others? ## Proposed Solution If you have ideas about how to implement the feature, share them here. ## Alternatives Considered Have you considered any alternative solutions or workarounds? ## Additional Context Add any other context, screenshots, or examples about the feature request here. ================================================ FILE: .github/workflows/claude.yml ================================================ name: Claude Code on: issue_comment: types: [created] pull_request_review_comment: types: [created] issues: types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 1 persist-credentials: false - name: Run Claude Code id: claude uses: anthropics/claude-code-action@8e84799f37d42f24e0ebae41205346879bdcab5a # v0.0.7/beta with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ================================================ FILE: .github/workflows/test.yml ================================================ name: Tests on: push: branches: [main] pull_request: branches: [main] permissions: contents: read # Cancel in-progress runs for pull requests when developers push # additional changes concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: unit-tests: runs-on: ubuntu-latest strategy: matrix: neovim-version: ["stable", "nightly"] steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 persist-credentials: false - name: Setup Nix uses: nixbuild/nix-quick-install-action@5bb6a3b3abe66fd09bbf250dce8ada94f856a703 # v30 - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 with: primary-key: nix-claudecode-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} restore-prefixes-first-match: nix-claudecode-${{ runner.os }}- gc-max-store-size-linux: 2G purge: true purge-prefixes: nix-claudecode-${{ runner.os }}- purge-created: 0 purge-primary-key: never - name: Setup Neovim uses: rhysd/action-setup-vim@8e931b9954b19d4203d5caa5ff5521f3bc21dcc7 # v1.4.2 with: neovim: true version: ${{ matrix.neovim-version }} - name: Run Luacheck run: nix develop .#ci -c make check - name: Run tests run: nix develop .#ci -c make test - name: Check formatting run: nix flake check - name: Generate coverage report run: | if [ -f "luacov.stats.out" ]; then nix develop .#ci -c luacov echo "Creating lcov.info from luacov.report.out" { echo "TN:" grep -E "^Summary$" -A1000 luacov.report.out | grep -E "^[^ ].*:" | while read -r line; do file=$(echo "$line" | cut -d':' -f1) echo "SF:$file" percent=$(echo "$line" | grep -oE "[0-9\.]+%" | tr -d '%') if [ -n "$percent" ]; then echo "DA:1,1" echo "LF:1" echo "LH:$percent" fi echo "end_of_record" done } > lcov.info { echo "## 📊 Test Coverage Report" echo "" if [ -f "luacov.report.out" ]; then overall_coverage=$(grep -E "Total.*%" luacov.report.out | grep -oE "[0-9]+\.[0-9]+%" | head -1) if [ -n "$overall_coverage" ]; then echo "**Overall Coverage: $overall_coverage**" echo "" fi echo "| File | Coverage |" echo "|------|----------|" grep -E "^[^ ].*:" luacov.report.out | while read -r line; do file=$(echo "$line" | cut -d':' -f1) percent=$(echo "$line" | grep -oE "[0-9]+\.[0-9]+%" | head -1) if [ -n "$percent" ]; then # Add emoji based on coverage level percent_num="${percent%.*}" if [ "$percent_num" -ge 90 ]; then emoji="🟢" elif [ "$percent_num" -ge 70 ]; then emoji="🟡" else emoji="🔴" fi echo "| \`$file\` | $emoji $percent |" fi done else echo "❌ No coverage report generated" fi } >> "$GITHUB_STEP_SUMMARY" else { echo "## 📊 Test Coverage Report" echo "" echo "❌ No coverage data found - tests may not have run with coverage enabled" } >> "$GITHUB_STEP_SUMMARY" fi integration-tests: runs-on: ubuntu-latest needs: unit-tests strategy: matrix: neovim-version: ["stable"] steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 persist-credentials: false - name: Setup Nix uses: nixbuild/nix-quick-install-action@5bb6a3b3abe66fd09bbf250dce8ada94f856a703 # v30 - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 with: primary-key: nix-claudecode-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} restore-prefixes-first-match: nix-claudecode-${{ runner.os }}- gc-max-store-size-linux: 2G purge: true purge-prefixes: nix-claudecode-${{ runner.os }}- purge-created: 0 purge-primary-key: never - name: Setup Neovim uses: rhysd/action-setup-vim@8e931b9954b19d4203d5caa5ff5521f3bc21dcc7 # v1.4.2 with: neovim: true version: ${{ matrix.neovim-version }} - name: Install test dependencies run: | git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim ln -s "$(pwd)" ~/.local/share/nvim/site/pack/vendor/start/claudecode.nvim - name: Run integration tests run: nix develop .#ci -c ./scripts/run_integration_tests_individually.sh ================================================ FILE: .github/workflows/update-changelog.yml ================================================ name: Update Changelog on: push: branches: [main] permissions: contents: write pull-requests: write jobs: update-changelog: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 with: fetch-depth: 2 # We need at least 2 commits to get the last one persist-credentials: true - name: Get commit message id: get-commit-msg shell: bash # zizmor-disable-next-line template-injection run: | # Get only the first line of commit message (subject line) COMMIT_MSG=$(git log -1 --pretty=%s) { echo "commit_msg=${COMMIT_MSG}" echo "commit_type=$(echo "${COMMIT_MSG}" | grep -o '^feat\|^fix\|^docs\|^style\|^refactor\|^perf\|^test\|^build\|^ci\|^chore' || echo 'other')" echo "commit_scope=$(echo "${COMMIT_MSG}" | grep -o '([^)]*)')" echo "commit_desc=$(echo "${COMMIT_MSG}" | sed -E 's/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\([^)]*\))?:\s*//')" } >> "$GITHUB_OUTPUT" - name: Update Changelog id: update-changelog shell: bash # zizmor-disable-next-line template-injection env: COMMIT_TYPE_ENV: ${{ steps.get-commit-msg.outputs.commit_type }} COMMIT_SCOPE_ENV: ${{ steps.get-commit-msg.outputs.commit_scope }} COMMIT_DESC_ENV: ${{ steps.get-commit-msg.outputs.commit_desc }} run: | # Assign environment variables directly to shell variables COMMIT_TYPE="$COMMIT_TYPE_ENV" COMMIT_SCOPE="$COMMIT_SCOPE_ENV" COMMIT_DESC="$COMMIT_DESC_ENV" # Clean up scope format for display SCOPE="" if [ -n "$COMMIT_SCOPE" ]; then SCOPE=$(echo "$COMMIT_SCOPE" | sed -E 's/^\(([^)]*)\)$/\1/') SCOPE=" ($SCOPE)" fi # Format changelog entry based on commit type ENTRY="- $COMMIT_DESC$SCOPE" # Determine which section to add the entry to SECTION="" case "$COMMIT_TYPE" in feat) SECTION="Added" ;; fix) SECTION="Fixed" ;; docs) SECTION="Documentation" ;; style|refactor) SECTION="Changed" ;; perf) SECTION="Performance" ;; test|build|ci|chore) # Skip these commit types for changelog echo "Skipping changelog update for commit type: $COMMIT_TYPE" exit 0 ;; *) # For other commit types, put in Changed section SECTION="Changed" ;; esac # Prepare a new branch BRANCH_NAME="changelog/update-$COMMIT_TYPE-$(date +%Y%m%d%H%M%S)" git checkout -b "$BRANCH_NAME" # Check if the section exists, if not, create it if ! grep -q "### $SECTION" CHANGELOG.md; then # Add the section after "## [Unreleased]" line sed -i "/## \[Unreleased\]/a \\\n### $SECTION\n" CHANGELOG.md fi # Add entry to the section # Ensure ENTRY is quoted to handle potential special characters if it were used in a more complex sed command, # but for 'a' (append), it's generally safer. sed -i "/### $SECTION/a $ENTRY" CHANGELOG.md # Check if any changes were made if git diff --quiet CHANGELOG.md; then echo "No changes made to CHANGELOG.md" # No temp files to clean up here for commit details if exiting early exit 0 fi # Write outputs directly to GITHUB_OUTPUT using a block redirect { echo "changelog_updated=true" echo "branch_name=$BRANCH_NAME" echo "commit_type=$COMMIT_TYPE" echo "commit_desc=$COMMIT_DESC" echo "section=$SECTION" } >> "$GITHUB_OUTPUT" # No temp files like commit_type.txt, etc. were created in this block anymore - name: Set up git identity for Claude if: steps.update-changelog.outputs.changelog_updated == 'true' shell: bash # zizmor-disable-next-line template-injection run: | git config --local user.email "noreply@anthropic.com" git config --local user.name "Claude" - name: Commit changes if: steps.update-changelog.outputs.changelog_updated == 'true' shell: bash # zizmor-disable-next-line template-injection env: COMMIT_TYPE_ENV: ${{ steps.get-commit-msg.outputs.commit_type }} COMMIT_SCOPE_ENV: ${{ steps.get-commit-msg.outputs.commit_scope }} BRANCH_NAME_ENV: ${{ steps.update-changelog.outputs.branch_name }} run: | # Assign environment variables directly to shell variables COMMIT_TYPE="$COMMIT_TYPE_ENV" COMMIT_SCOPE="$COMMIT_SCOPE_ENV" BRANCH_NAME="$BRANCH_NAME_ENV" git add CHANGELOG.md git commit -m "docs(changelog): update for ${COMMIT_TYPE}${COMMIT_SCOPE}" git push --set-upstream origin "$BRANCH_NAME" # No temp files to clean up here - name: Create Pull Request if: steps.update-changelog.outputs.changelog_updated == 'true' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const commitType = process.env.COMMIT_TYPE; const commitDesc = process.env.COMMIT_DESC; const section = process.env.SECTION; const branchName = process.env.BRANCH_NAME; try { const pr = await github.rest.pulls.create({ owner: context.repo.owner, repo: context.repo.repo, title: `docs(changelog): Update ${section} section for ${commitType} changes`, head: branchName, base: 'main', body: `This PR was automatically generated to update the CHANGELOG.md file. ## Changes Added to the **${section}** section: \`\`\` - ${commitDesc} \`\`\` This change reflects the latest commit to the main branch. --- 🤖 Generated with [Claude Code](https://claude.ai/code)` }); console.log(`Pull Request created: ${pr.data.html_url}`); } catch (error) { console.error('Error creating pull request:', error); core.setFailed('Failed to create pull request'); } env: COMMIT_TYPE: ${{ steps.update-changelog.outputs.commit_type }} COMMIT_DESC: ${{ steps.update-changelog.outputs.commit_desc }} SECTION: ${{ steps.update-changelog.outputs.section }} BRANCH_NAME: ${{ steps.update-changelog.outputs.branch_name }}