├── fixtures ├── oil │ ├── init.lua │ ├── lua │ │ ├── plugins │ │ │ ├── dev-claudecode.lua │ │ │ ├── init.lua │ │ │ └── oil-nvim.lua │ │ └── config │ │ │ └── lazy.lua │ └── lazy-lock.json ├── netrw │ ├── init.lua │ ├── lua │ │ ├── plugins │ │ │ ├── dev-claudecode.lua │ │ │ ├── init.lua │ │ │ ├── snacks.lua │ │ │ └── netrw-keymaps.lua │ │ └── config │ │ │ ├── netrw.lua │ │ │ └── lazy.lua │ └── lazy-lock.json ├── mini-files │ ├── init.lua │ ├── lua │ │ ├── plugins │ │ │ ├── dev-claudecode.lua │ │ │ ├── init.lua │ │ │ └── mini-files.lua │ │ └── config │ │ │ └── lazy.lua │ └── lazy-lock.json ├── nvim-tree │ ├── init.lua │ ├── lua │ │ ├── plugins │ │ │ ├── dev-claudecode.lua │ │ │ ├── init.lua │ │ │ └── nvim-tree.lua │ │ └── config │ │ │ └── lazy.lua │ └── lazy-lock.json ├── bin │ ├── list-configs │ ├── vv │ ├── common.sh │ └── vve └── nvim-aliases.sh ├── .stylua.toml ├── tests ├── integration │ └── basic_spec.lua ├── simple_test.lua ├── helpers │ └── setup.lua ├── init.lua ├── unit │ ├── terminal │ │ └── none_provider_spec.lua │ ├── tools │ │ ├── get_workspace_folders_spec.lua │ │ ├── get_latest_selection_spec.lua │ │ ├── close_all_diff_tabs_spec.lua │ │ ├── check_document_dirty_spec.lua │ │ └── get_current_selection_spec.lua │ ├── new_file_reject_then_reopen_spec.lua │ ├── focus_after_send_spec.lua │ ├── diff_ui_cleanup_spec.lua │ ├── diff_hide_terminal_new_tab_spec.lua │ ├── directory_at_mention_spec.lua │ ├── opendiff_blocking_spec.lua │ ├── logger_spec.lua │ ├── tree_send_visual_spec.lua │ └── oil_integration_spec.lua ├── minimal_init.lua └── config_test.lua ├── .claude ├── settings.json └── hooks │ └── format.sh ├── .envrc ├── lua └── claudecode │ ├── utils.lua │ ├── tools │ ├── get_latest_selection.lua │ ├── check_document_dirty.lua │ ├── get_workspace_folders.lua │ ├── save_document.lua │ ├── open_diff.lua │ ├── close_all_diff_tabs.lua │ ├── get_current_selection.lua │ ├── get_diagnostics.lua │ ├── get_open_editors.lua │ └── close_tab.lua │ ├── terminal │ └── none.lua │ ├── cwd.lua │ ├── logger.lua │ └── types.lua ├── .devcontainer ├── devcontainer.json ├── Dockerfile └── post-create.sh ├── scripts ├── websocat.sh ├── test_opendiff_simple.sh ├── run_integration_tests_individually.sh ├── manual_test_helper.lua ├── research_messages.sh ├── claude_shell_helpers.sh └── test_opendiff.lua ├── .luacheckrc ├── plugin └── claudecode.lua ├── LICENSE ├── Makefile ├── flake.nix ├── AGENTS.md ├── flake.lock ├── STORY.md └── dev-config.lua /fixtures/oil/init.lua: -------------------------------------------------------------------------------- 1 | require("config.lazy") 2 | -------------------------------------------------------------------------------- /fixtures/netrw/init.lua: -------------------------------------------------------------------------------- 1 | require("config.lazy") 2 | -------------------------------------------------------------------------------- /fixtures/mini-files/init.lua: -------------------------------------------------------------------------------- 1 | require("config.lazy") 2 | -------------------------------------------------------------------------------- /fixtures/nvim-tree/init.lua: -------------------------------------------------------------------------------- 1 | require("config.lazy") 2 | -------------------------------------------------------------------------------- /fixtures/netrw/lua/plugins/dev-claudecode.lua: -------------------------------------------------------------------------------- 1 | ../../../../dev-config.lua -------------------------------------------------------------------------------- /fixtures/oil/lua/plugins/dev-claudecode.lua: -------------------------------------------------------------------------------- 1 | ../../../../dev-config.lua -------------------------------------------------------------------------------- /fixtures/mini-files/lua/plugins/dev-claudecode.lua: -------------------------------------------------------------------------------- 1 | ../../../../dev-config.lua -------------------------------------------------------------------------------- /fixtures/nvim-tree/lua/plugins/dev-claudecode.lua: -------------------------------------------------------------------------------- 1 | ../../../../dev-config.lua -------------------------------------------------------------------------------- /fixtures/netrw/lazy-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, 3 | "tokyonight.nvim": { "branch": "main", "commit": "4d159616aee17796c2c94d2f5f87d2ee1a3f67c7" } 4 | } 5 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | # StyLua configuration 2 | 3 | column_width = 120 4 | line_endings = "Unix" 5 | indent_type = "Spaces" 6 | indent_width = 2 7 | quote_style = "AutoPreferDouble" 8 | call_parentheses = "Always" 9 | 10 | [sort_requires] 11 | enabled = true -------------------------------------------------------------------------------- /tests/integration/basic_spec.lua: -------------------------------------------------------------------------------- 1 | local assert = require("luassert") 2 | 3 | describe("Claudecode Integration", function() 4 | it("should pass placeholder test", function() 5 | -- Simple placeholder test that will always pass 6 | assert.is_true(true) 7 | end) 8 | end) 9 | -------------------------------------------------------------------------------- /fixtures/netrw/lua/plugins/init.lua: -------------------------------------------------------------------------------- 1 | -- Basic plugin configuration 2 | return { 3 | -- Example: add a colorscheme 4 | { 5 | "folke/tokyonight.nvim", 6 | lazy = false, 7 | priority = 1000, 8 | config = function() 9 | vim.cmd([[colorscheme tokyonight]]) 10 | end, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /fixtures/oil/lua/plugins/init.lua: -------------------------------------------------------------------------------- 1 | -- Basic plugin configuration 2 | return { 3 | -- Example: add a colorscheme 4 | { 5 | "folke/tokyonight.nvim", 6 | lazy = false, 7 | priority = 1000, 8 | config = function() 9 | vim.cmd([[colorscheme tokyonight]]) 10 | end, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /fixtures/mini-files/lua/plugins/init.lua: -------------------------------------------------------------------------------- 1 | -- Basic plugin configuration 2 | return { 3 | -- Example: add a colorscheme 4 | { 5 | "folke/tokyonight.nvim", 6 | lazy = false, 7 | priority = 1000, 8 | config = function() 9 | vim.cmd([[colorscheme tokyonight]]) 10 | end, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /fixtures/nvim-tree/lua/plugins/init.lua: -------------------------------------------------------------------------------- 1 | -- Basic plugin configuration 2 | return { 3 | -- Example: add a colorscheme 4 | { 5 | "folke/tokyonight.nvim", 6 | lazy = false, 7 | priority = 1000, 8 | config = function() 9 | vim.cmd([[colorscheme tokyonight]]) 10 | end, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /fixtures/mini-files/lazy-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, 3 | "mini.files": { "branch": "main", "commit": "d22c5b74b7764d0bd33e4988e5ee00139cfe22e3" }, 4 | "tokyonight.nvim": { "branch": "main", "commit": "4d159616aee17796c2c94d2f5f87d2ee1a3f67c7" } 5 | } 6 | -------------------------------------------------------------------------------- /.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "PostToolUse": [ 4 | { 5 | "matcher": "Write|Edit", 6 | "hooks": [ 7 | { 8 | "type": "command", 9 | "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh" 10 | } 11 | ] 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! has nix_direnv_version || ! nix_direnv_version 3.0.7; then 4 | source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.7/direnvrc" "sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM=" 5 | fi 6 | 7 | nix_direnv_manual_reload 8 | 9 | use flake . 10 | 11 | # Add fixtures/bin to PATH for nvim config aliases 12 | PATH_add fixtures/bin 13 | -------------------------------------------------------------------------------- /fixtures/oil/lazy-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, 3 | "mini.icons": { "branch": "main", "commit": "e8fae66cb400744daeedf6e387347df50271c252" }, 4 | "oil.nvim": { "branch": "master", "commit": "919e155fdf38e9148cdb5304faaaf53c20d703ea" }, 5 | "tokyonight.nvim": { "branch": "main", "commit": "4d159616aee17796c2c94d2f5f87d2ee1a3f67c7" } 6 | } 7 | -------------------------------------------------------------------------------- /fixtures/nvim-tree/lazy-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "lazy.nvim": { "branch": "main", "commit": "6c3bda4aca61a13a9c63f1c1d1b16b9d3be90d7a" }, 3 | "nvim-tree.lua": { "branch": "master", "commit": "e179ad2f83b5955ab0af653069a493a1828c2697" }, 4 | "nvim-web-devicons": { "branch": "master", "commit": "f6b0920f452bfd7595ee9a9efe5e1ae78e0e2997" }, 5 | "tokyonight.nvim": { "branch": "main", "commit": "4d159616aee17796c2c94d2f5f87d2ee1a3f67c7" } 6 | } 7 | -------------------------------------------------------------------------------- /fixtures/netrw/lua/plugins/snacks.lua: -------------------------------------------------------------------------------- 1 | if true then 2 | return {} 3 | end 4 | 5 | return { 6 | "folke/snacks.nvim", 7 | priority = 1000, 8 | lazy = false, 9 | opts = { 10 | bigfile = { enabled = true }, 11 | dashboard = { enabled = true }, 12 | explorer = { enabled = true }, 13 | notifier = { enabled = true }, 14 | quickfile = { enabled = true }, 15 | statuscolumn = { enabled = true }, 16 | words = { enabled = true }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /lua/claudecode/utils.lua: -------------------------------------------------------------------------------- 1 | ---Shared utility functions for claudecode.nvim 2 | ---@module 'claudecode.utils' 3 | 4 | local M = {} 5 | 6 | ---Normalizes focus parameter to default to true for backward compatibility 7 | ---@param focus boolean? The focus parameter 8 | ---@return boolean valid Whether the focus parameter is valid 9 | function M.normalize_focus(focus) 10 | if focus == nil then 11 | return true 12 | else 13 | return focus 14 | end 15 | end 16 | 17 | return M 18 | -------------------------------------------------------------------------------- /fixtures/bin/list-configs: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # list-configs - Show available Neovim configurations 4 | 5 | FIXTURES_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)" 6 | 7 | # Source common functions 8 | source "$FIXTURES_DIR/bin/common.sh" 9 | 10 | echo "Available Neovim test configurations:" 11 | get_configs "$FIXTURES_DIR" | while read -r config; do 12 | if [[ -d "$FIXTURES_DIR/$config" ]]; then 13 | echo " ✓ $config" 14 | else 15 | echo " ✗ $config (missing)" 16 | fi 17 | done 18 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "claudecode.nvim Development", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "features": { 7 | "ghcr.io/devcontainers/features/git:1": {} 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "settings": { 12 | "terminal.integrated.defaultProfile.linux": "bash" 13 | }, 14 | "extensions": ["jnoortheen.nix-ide"] 15 | } 16 | }, 17 | "postCreateCommand": "bash .devcontainer/post-create.sh", 18 | "remoteUser": "vscode" 19 | } 20 | -------------------------------------------------------------------------------- /scripts/websocat.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | CLAUDE_LIB_DIR="$(dirname "$(realpath "$0")")" 4 | 5 | # shellcheck source=./lib_claude.sh 6 | source "$CLAUDE_LIB_DIR/lib_claude.sh" 7 | 8 | websocat "$(get_claude_ws_url)" 9 | 10 | # Tools list 11 | # { "jsonrpc": "2.0", "id": "tools-list-test", "method": "tools/list", "params": {} } 12 | # 13 | # {"jsonrpc":"2.0","id":"direct-1","method":"getCurrentSelection","params":{}} 14 | # 15 | # { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "getCurrentSelection", "arguments": { } } } 16 | -------------------------------------------------------------------------------- /tests/simple_test.lua: -------------------------------------------------------------------------------- 1 | -- Simple test without using the mock vim API 2 | 3 | describe("Simple Tests", function() 4 | describe("Math operations", function() 5 | it("should add numbers correctly", function() 6 | assert.are.equal(4, 2 + 2) 7 | end) 8 | 9 | it("should multiply numbers correctly", function() 10 | assert.are.equal(6, 2 * 3) 11 | end) 12 | end) 13 | 14 | describe("String operations", function() 15 | it("should concatenate strings", function() 16 | assert.are.equal("Hello World", "Hello " .. "World") 17 | end) 18 | end) 19 | end) 20 | -------------------------------------------------------------------------------- /fixtures/nvim-tree/lua/plugins/nvim-tree.lua: -------------------------------------------------------------------------------- 1 | return { 2 | "nvim-tree/nvim-tree.lua", 3 | dependencies = { 4 | "nvim-tree/nvim-web-devicons", 5 | }, 6 | config = function() 7 | require("nvim-tree").setup({ 8 | view = { 9 | width = 30, 10 | }, 11 | renderer = { 12 | group_empty = true, 13 | }, 14 | filters = { 15 | dotfiles = true, 16 | }, 17 | }) 18 | 19 | -- Key mappings 20 | vim.keymap.set("n", "", ":NvimTreeToggle", { silent = true }) 21 | vim.keymap.set("n", "e", ":NvimTreeFocus", { silent = true }) 22 | end, 23 | } 24 | -------------------------------------------------------------------------------- /fixtures/nvim-aliases.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test Neovim configurations with fixture configs 4 | # This script provides aliases that call the executable scripts in bin/ 5 | 6 | # Get script directory 7 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 8 | BIN_DIR="$SCRIPT_DIR/bin" 9 | 10 | # Create aliases that call the bin scripts 11 | # shellcheck disable=SC2139 12 | alias vv="$BIN_DIR/vv" 13 | # shellcheck disable=SC2139 14 | alias vve="$BIN_DIR/vve" 15 | # shellcheck disable=SC2139 16 | alias list-configs="$BIN_DIR/list-configs" 17 | 18 | echo "Neovim configuration aliases loaded!" 19 | echo "Use 'vv ' or 'vve ' to test configurations" 20 | echo "Use 'list-configs' to see available options" 21 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | -- Luacheck configuration for Claude Code Neovim plugin 2 | 3 | -- Set global variable names 4 | globals = { 5 | "vim", 6 | "expect", 7 | "assert_contains", 8 | "assert_not_contains", 9 | "spy", -- For luassert.spy and spy.any 10 | } 11 | 12 | -- Ignore warnings for unused self parameters 13 | self = false 14 | 15 | -- Allow trailing whitespace 16 | ignore = { 17 | "212/self", -- Unused argument 'self' 18 | "631", -- Line contains trailing whitespace 19 | } 20 | 21 | -- Set max line length 22 | max_line_length = 120 23 | 24 | -- Allow using external modules 25 | allow_defined_top = true 26 | allow_defined = true 27 | 28 | -- Enable more checking 29 | std = "luajit+busted" 30 | 31 | -- Ignore tests/ directory for performance 32 | exclude_files = { 33 | "tests/mocks", 34 | } 35 | 36 | -------------------------------------------------------------------------------- /fixtures/netrw/lua/config/netrw.lua: -------------------------------------------------------------------------------- 1 | -- Netrw configuration for file browsing 2 | -- This replaces file managers like nvim-tree or oil.nvim 3 | 4 | -- Configure netrw settings early 5 | vim.g.loaded_netrw = nil 6 | vim.g.loaded_netrwPlugin = nil 7 | 8 | -- Netrw settings 9 | vim.g.netrw_banner = 0 -- Hide banner 10 | vim.g.netrw_liststyle = 3 -- Tree view 11 | vim.g.netrw_browse_split = 4 -- Open in previous window 12 | vim.g.netrw_altv = 1 -- Split to the right 13 | vim.g.netrw_winsize = 25 -- 25% width 14 | vim.g.netrw_keepdir = 0 -- Keep current dir in sync 15 | vim.g.netrw_localcopydircmd = "cp -r" 16 | 17 | -- Hide dotfiles by default (toggle with gh) 18 | vim.g.netrw_list_hide = [[.*\..*]] 19 | vim.g.netrw_hide = 1 20 | 21 | -- Use system open command 22 | if vim.fn.has("mac") == 1 then 23 | vim.g.netrw_browsex_viewer = "open" 24 | elseif vim.fn.has("unix") == 1 then 25 | vim.g.netrw_browsex_viewer = "xdg-open" 26 | end 27 | -------------------------------------------------------------------------------- /fixtures/bin/vv: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # vv - Start Neovim with fixture configuration 4 | 5 | FIXTURES_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)" 6 | 7 | # Source common functions 8 | source "$FIXTURES_DIR/bin/common.sh" 9 | 10 | # Main logic 11 | if [[ $# -eq 0 ]]; then 12 | config="$(select_config "$FIXTURES_DIR")" 13 | [[ -z $config ]] && echo "No config selected" && exit 0 14 | else 15 | config="$1" 16 | fi 17 | 18 | if ! validate_config "$FIXTURES_DIR" "$config"; then 19 | exit 1 20 | fi 21 | 22 | # Set environment to use the config directory as if it were ~/.config/nvim 23 | config_dir="$FIXTURES_DIR/$config" 24 | init_file="$config_dir/init.lua" 25 | 26 | if [[ -f "$init_file" ]]; then 27 | echo "Loading config from: $config_dir" 28 | (cd "$FIXTURES_DIR" && NVIM_APPNAME="$config" XDG_CONFIG_HOME="$FIXTURES_DIR" nvim "${@:2}") 29 | else 30 | echo "Error: $init_file not found" 31 | exit 1 32 | fi 33 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/base:ubuntu 2 | 3 | # Install Nix 4 | RUN apt-get update && apt-get install -y \ 5 | curl \ 6 | xz-utils \ 7 | sudo \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | # Create vscode user if it doesn't exist 11 | RUN if ! id -u vscode > /dev/null 2>&1; then \ 12 | useradd -m -s /bin/bash vscode && \ 13 | echo "vscode ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers; \ 14 | fi 15 | 16 | # Switch to vscode user for Nix installation 17 | USER vscode 18 | WORKDIR /home/vscode 19 | 20 | # Install Nix in single-user mode 21 | RUN curl -L https://nixos.org/nix/install | sh -s -- --no-daemon 22 | 23 | # Add Nix to PATH and configure for the shell 24 | RUN echo '. /home/vscode/.nix-profile/etc/profile.d/nix.sh' >> /home/vscode/.bashrc && \ 25 | mkdir -p /home/vscode/.config/nix && \ 26 | echo 'experimental-features = nix-command flakes' >> /home/vscode/.config/nix/nix.conf 27 | 28 | # Keep container running 29 | CMD ["sleep", "infinity"] 30 | -------------------------------------------------------------------------------- /.devcontainer/post-create.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Source Nix environment 4 | . /home/vscode/.nix-profile/etc/profile.d/nix.sh 5 | 6 | # Verify Nix is available 7 | if ! command -v nix &>/dev/null; then 8 | echo "Error: Nix is not installed properly" 9 | exit 1 10 | fi 11 | 12 | echo "✅ Nix is installed and available" 13 | echo "" 14 | echo "📦 claudecode.nvim Development Container Ready!" 15 | echo "" 16 | echo "To enter the development shell with all dependencies, run:" 17 | echo " nix develop" 18 | echo "" 19 | echo "This will provide:" 20 | echo " - Neovim" 21 | echo " - Lua and LuaJIT" 22 | echo " - busted (test framework)" 23 | echo " - luacheck (linter)" 24 | echo " - stylua (formatter)" 25 | echo " - All other development tools" 26 | echo "" 27 | echo "You can also run development commands directly:" 28 | echo " - make # Run full validation (format, lint, test)" 29 | echo " - make test # Run tests" 30 | echo " - make check # Run linter" 31 | echo " - make format # Format code" 32 | -------------------------------------------------------------------------------- /plugin/claudecode.lua: -------------------------------------------------------------------------------- 1 | if vim.fn.has("nvim-0.8.0") ~= 1 then 2 | vim.api.nvim_err_writeln("Claude Code requires Neovim >= 0.8.0") 3 | return 4 | end 5 | 6 | if vim.g.loaded_claudecode then 7 | return 8 | end 9 | vim.g.loaded_claudecode = 1 10 | 11 | --- Example: In your `init.lua`, you can set `vim.g.claudecode_auto_setup = { auto_start = true }` 12 | --- to automatically start ClaudeCode when Neovim loads. 13 | if vim.g.claudecode_auto_setup then 14 | vim.defer_fn(function() 15 | require("claudecode").setup(vim.g.claudecode_auto_setup) 16 | end, 0) 17 | end 18 | 19 | -- Commands are now registered in lua/claudecode/init.lua's _create_commands function 20 | -- when require("claudecode").setup() is called. 21 | -- This file (plugin/claudecode.lua) is primarily for the load guard 22 | -- and the optional auto-setup mechanism. 23 | 24 | local main_module_ok, _ = pcall(require, "claudecode") 25 | if not main_module_ok then 26 | vim.notify("ClaudeCode: Failed to load main module. Plugin may not function correctly.", vim.log.levels.ERROR) 27 | end 28 | -------------------------------------------------------------------------------- /tests/helpers/setup.lua: -------------------------------------------------------------------------------- 1 | -- Test environment setup 2 | 3 | -- This function sets up the test environment 4 | return function() 5 | -- Create mock vim API if we're running tests outside of Neovim 6 | if not vim then 7 | -- luacheck: ignore 8 | _G.vim = require("tests.mocks.vim") 9 | end 10 | 11 | -- Setup test globals 12 | _G.assert = require("luassert") 13 | _G.stub = require("luassert.stub") 14 | _G.spy = require("luassert.spy") 15 | _G.mock = require("luassert.mock") 16 | 17 | -- Helper function to verify a test passes 18 | _G.it = function(desc, fn) 19 | local ok, err = pcall(fn) 20 | if not ok then 21 | print("FAIL: " .. desc) 22 | print(err) 23 | error("Test failed: " .. desc) 24 | else 25 | print("PASS: " .. desc) 26 | end 27 | end 28 | 29 | -- Helper function to describe a test group 30 | _G.describe = function(desc, fn) 31 | print("\n==== " .. desc .. " ====") 32 | fn() 33 | end 34 | 35 | -- Load the plugin under test 36 | package.loaded["claudecode"] = nil 37 | 38 | -- Return true to indicate setup was successful 39 | return true 40 | end 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2025 Coder Technologies Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /fixtures/bin/common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # common.sh - Shared functions for fixture scripts 4 | 5 | # Get available configurations 6 | get_configs() { 7 | local fixtures_dir="$1" 8 | find "$fixtures_dir" -maxdepth 1 -type d \ 9 | ! -name ".*" \ 10 | ! -name "fixtures" \ 11 | ! -name "bin" \ 12 | ! -path "$fixtures_dir" \ 13 | -exec basename {} \; | sort 14 | } 15 | 16 | # Validate config exists 17 | validate_config() { 18 | local fixtures_dir="$1" 19 | local config="$2" 20 | 21 | if [[ ! -d "$fixtures_dir/$config" ]]; then 22 | echo "Error: Configuration '$config' not found in fixtures/" 23 | echo "Available configs:" 24 | get_configs "$fixtures_dir" | while read -r c; do 25 | echo " • $c" 26 | done 27 | return 1 28 | fi 29 | return 0 30 | } 31 | 32 | # Interactive config selection 33 | select_config() { 34 | local fixtures_dir="$1" 35 | 36 | if command -v fzf >/dev/null 2>&1; then 37 | get_configs "$fixtures_dir" | fzf --prompt="Neovim Configs > " --height=~50% --layout=reverse --border --exit-0 38 | else 39 | echo "Available configs:" 40 | get_configs "$fixtures_dir" | while read -r config; do 41 | echo " • $config" 42 | done 43 | echo -n "Select config: " 44 | read -r config 45 | echo "$config" 46 | fi 47 | } 48 | -------------------------------------------------------------------------------- /tests/init.lua: -------------------------------------------------------------------------------- 1 | -- Test runner for Claude Code Neovim integration 2 | 3 | local M = {} 4 | 5 | -- Run all tests 6 | function M.run() 7 | -- Set up minimal test environment 8 | require("tests.helpers.setup")() 9 | 10 | -- Discover and run all tests 11 | M.run_unit_tests() 12 | M.run_component_tests() 13 | M.run_integration_tests() 14 | 15 | -- Report results 16 | M.report_results() 17 | end 18 | 19 | -- Run unit tests 20 | function M.run_unit_tests() 21 | -- Run all unit tests 22 | require("tests.unit.config_spec") 23 | require("tests.unit.server_spec") 24 | require("tests.unit.tools_spec") 25 | require("tests.unit.selection_spec") 26 | require("tests.unit.lockfile_spec") 27 | end 28 | 29 | -- Run component tests 30 | function M.run_component_tests() 31 | -- Run all component tests 32 | require("tests.component.server_spec") 33 | require("tests.component.tools_spec") 34 | end 35 | 36 | -- Run integration tests 37 | function M.run_integration_tests() 38 | -- Run all integration tests 39 | require("tests.integration.e2e_spec") 40 | end 41 | 42 | -- Report test results 43 | function M.report_results() 44 | -- Print test summary 45 | print("All tests completed!") 46 | -- In a real implementation, this would report 47 | -- detailed test statistics 48 | end 49 | 50 | return M 51 | -------------------------------------------------------------------------------- /fixtures/bin/vve: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # vve - Edit Neovim configuration for a given fixture 4 | 5 | FIXTURES_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)" 6 | 7 | # Source common functions 8 | source "$FIXTURES_DIR/bin/common.sh" 9 | 10 | # Main logic 11 | if [[ $# -eq 0 ]]; then 12 | config="$(select_config "$FIXTURES_DIR")" 13 | [[ -z $config ]] && echo "No config selected" && exit 0 14 | # Open the config directory for editing 15 | config_path="$FIXTURES_DIR/$config" 16 | else 17 | config="$1" 18 | # Validate that config is not empty when provided as argument 19 | if [[ -z "$config" ]]; then 20 | echo "Error: Config name cannot be empty" 21 | echo "Usage: vve [config] [file]" 22 | echo "Available configs:" 23 | get_configs "$FIXTURES_DIR" | while read -r c; do 24 | echo " • $c" 25 | done 26 | exit 1 27 | fi 28 | if [[ $# -gt 1 ]]; then 29 | # Specific file provided - open that file in the config directory 30 | config_path="$FIXTURES_DIR/$config/$2" 31 | else 32 | # No specific file - open the config directory 33 | config_path="$FIXTURES_DIR/$config" 34 | fi 35 | fi 36 | 37 | if ! validate_config "$FIXTURES_DIR" "$config"; then 38 | exit 1 39 | fi 40 | 41 | echo "Editing config: $config_path" 42 | 43 | # Use Neovim to edit the configuration files 44 | nvim "$config_path" 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check format test clean 2 | 3 | # Default target 4 | all: format check test 5 | 6 | # Detect if we are already inside a Nix shell 7 | ifeq (,$(IN_NIX_SHELL)) 8 | NIX_PREFIX := nix develop .#ci -c 9 | else 10 | NIX_PREFIX := 11 | endif 12 | 13 | # Check for syntax errors 14 | check: 15 | @echo "Checking Lua files for syntax errors..." 16 | $(NIX_PREFIX) find lua -name "*.lua" -type f -exec lua -e "assert(loadfile('{}'))" \; 17 | @echo "Running luacheck..." 18 | $(NIX_PREFIX) luacheck lua/ tests/ --no-unused-args --no-max-line-length 19 | 20 | # Format all files 21 | format: 22 | nix fmt 23 | 24 | # Run tests 25 | test: 26 | @echo "Running all tests..." 27 | @export LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;./?/init.lua;$$LUA_PATH"; \ 28 | TEST_FILES=$$(find tests -type f -name "*_test.lua" -o -name "*_spec.lua" | sort); \ 29 | echo "Found test files:"; \ 30 | echo "$$TEST_FILES"; \ 31 | if [ -n "$$TEST_FILES" ]; then \ 32 | $(NIX_PREFIX) busted --coverage -v $$TEST_FILES; \ 33 | else \ 34 | echo "No test files found"; \ 35 | fi 36 | 37 | # Clean generated files 38 | clean: 39 | @echo "Cleaning generated files..." 40 | @rm -f luacov.report.out luacov.stats.out 41 | @rm -f tests/lcov.info 42 | 43 | # Print available commands 44 | help: 45 | @echo "Available commands:" 46 | @echo " make check - Check for syntax errors" 47 | @echo " make format - Format all files (uses nix fmt or stylua)" 48 | @echo " make test - Run tests" 49 | @echo " make clean - Clean generated files" 50 | @echo " make help - Print this help message" 51 | -------------------------------------------------------------------------------- /fixtures/netrw/lua/config/lazy.lua: -------------------------------------------------------------------------------- 1 | -- Bootstrap lazy.nvim 2 | local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" 3 | if not (vim.uv or vim.loop).fs_stat(lazypath) then 4 | local lazyrepo = "https://github.com/folke/lazy.nvim.git" 5 | local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) 6 | if vim.v.shell_error ~= 0 then 7 | vim.api.nvim_echo({ 8 | { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, 9 | { out, "WarningMsg" }, 10 | { "\nPress any key to exit..." }, 11 | }, true, {}) 12 | vim.fn.getchar() 13 | os.exit(1) 14 | end 15 | end 16 | vim.opt.rtp:prepend(lazypath) 17 | 18 | -- Make sure to setup `mapleader` and `maplocalleader` before 19 | -- loading lazy.nvim so that mappings are correct. 20 | -- This is also a good place to setup other settings (vim.opt) 21 | vim.g.mapleader = " " 22 | vim.g.maplocalleader = "\\" 23 | 24 | -- Setup lazy.nvim 25 | require("lazy").setup({ 26 | spec = { 27 | -- import your plugins 28 | { import = "plugins" }, 29 | }, 30 | -- Configure any other settings here. See the documentation for more details. 31 | -- colorscheme that will be used when installing plugins. 32 | install = { colorscheme = { "habamax" } }, 33 | -- automatically check for plugin updates 34 | checker = { enabled = true }, 35 | }) 36 | 37 | -- Add keybind for Lazy plugin manager 38 | vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) 39 | 40 | -- Terminal keybindings 41 | vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) 42 | -------------------------------------------------------------------------------- /fixtures/oil/lua/config/lazy.lua: -------------------------------------------------------------------------------- 1 | -- Bootstrap lazy.nvim 2 | local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" 3 | if not (vim.uv or vim.loop).fs_stat(lazypath) then 4 | local lazyrepo = "https://github.com/folke/lazy.nvim.git" 5 | local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) 6 | if vim.v.shell_error ~= 0 then 7 | vim.api.nvim_echo({ 8 | { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, 9 | { out, "WarningMsg" }, 10 | { "\nPress any key to exit..." }, 11 | }, true, {}) 12 | vim.fn.getchar() 13 | os.exit(1) 14 | end 15 | end 16 | vim.opt.rtp:prepend(lazypath) 17 | 18 | -- Make sure to setup `mapleader` and `maplocalleader` before 19 | -- loading lazy.nvim so that mappings are correct. 20 | -- This is also a good place to setup other settings (vim.opt) 21 | vim.g.mapleader = " " 22 | vim.g.maplocalleader = "\\" 23 | 24 | -- Setup lazy.nvim 25 | require("lazy").setup({ 26 | spec = { 27 | -- import your plugins 28 | { import = "plugins" }, 29 | }, 30 | -- Configure any other settings here. See the documentation for more details. 31 | -- colorscheme that will be used when installing plugins. 32 | install = { colorscheme = { "habamax" } }, 33 | -- automatically check for plugin updates 34 | checker = { enabled = true }, 35 | }) 36 | 37 | -- Add keybind for Lazy plugin manager 38 | vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) 39 | 40 | -- Terminal keybindings 41 | vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) 42 | -------------------------------------------------------------------------------- /fixtures/mini-files/lua/config/lazy.lua: -------------------------------------------------------------------------------- 1 | -- Bootstrap lazy.nvim 2 | local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" 3 | if not (vim.uv or vim.loop).fs_stat(lazypath) then 4 | local lazyrepo = "https://github.com/folke/lazy.nvim.git" 5 | local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) 6 | if vim.v.shell_error ~= 0 then 7 | vim.api.nvim_echo({ 8 | { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, 9 | { out, "WarningMsg" }, 10 | { "\nPress any key to exit..." }, 11 | }, true, {}) 12 | vim.fn.getchar() 13 | os.exit(1) 14 | end 15 | end 16 | vim.opt.rtp:prepend(lazypath) 17 | 18 | -- Make sure to setup `mapleader` and `maplocalleader` before 19 | -- loading lazy.nvim so that mappings are correct. 20 | -- This is also a good place to setup other settings (vim.opt) 21 | vim.g.mapleader = " " 22 | vim.g.maplocalleader = "\\" 23 | 24 | -- Setup lazy.nvim 25 | require("lazy").setup({ 26 | spec = { 27 | -- import your plugins 28 | { import = "plugins" }, 29 | }, 30 | -- Configure any other settings here. See the documentation for more details. 31 | -- colorscheme that will be used when installing plugins. 32 | install = { colorscheme = { "habamax" } }, 33 | -- automatically check for plugin updates 34 | checker = { enabled = true }, 35 | }) 36 | 37 | -- Add keybind for Lazy plugin manager 38 | vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) 39 | 40 | -- Terminal keybindings 41 | vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) 42 | -------------------------------------------------------------------------------- /fixtures/nvim-tree/lua/config/lazy.lua: -------------------------------------------------------------------------------- 1 | -- Bootstrap lazy.nvim 2 | local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" 3 | if not (vim.uv or vim.loop).fs_stat(lazypath) then 4 | local lazyrepo = "https://github.com/folke/lazy.nvim.git" 5 | local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath }) 6 | if vim.v.shell_error ~= 0 then 7 | vim.api.nvim_echo({ 8 | { "Failed to clone lazy.nvim:\n", "ErrorMsg" }, 9 | { out, "WarningMsg" }, 10 | { "\nPress any key to exit..." }, 11 | }, true, {}) 12 | vim.fn.getchar() 13 | os.exit(1) 14 | end 15 | end 16 | vim.opt.rtp:prepend(lazypath) 17 | 18 | -- Make sure to setup `mapleader` and `maplocalleader` before 19 | -- loading lazy.nvim so that mappings are correct. 20 | -- This is also a good place to setup other settings (vim.opt) 21 | vim.g.mapleader = " " 22 | vim.g.maplocalleader = "\\" 23 | 24 | -- Setup lazy.nvim 25 | require("lazy").setup({ 26 | spec = { 27 | -- import your plugins 28 | { import = "plugins" }, 29 | }, 30 | -- Configure any other settings here. See the documentation for more details. 31 | -- colorscheme that will be used when installing plugins. 32 | install = { colorscheme = { "habamax" } }, 33 | -- automatically check for plugin updates 34 | checker = { enabled = true }, 35 | }) 36 | 37 | -- Add keybind for Lazy plugin manager 38 | vim.keymap.set("n", "l", "Lazy", { desc = "Lazy Plugin Manager" }) 39 | 40 | -- Terminal keybindings 41 | vim.keymap.set("t", "", "", { desc = "Exit terminal mode (double esc)" }) 42 | -------------------------------------------------------------------------------- /lua/claudecode/tools/get_latest_selection.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for getting the latest text selection. 2 | 3 | local schema = { 4 | description = "Get the most recent text selection (even if not in the active editor)", 5 | inputSchema = { 6 | type = "object", 7 | additionalProperties = false, 8 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 9 | }, 10 | } 11 | 12 | ---Handles the getLatestSelection tool invocation. 13 | ---Gets the most recent text selection, even if not in the current active editor. 14 | ---This is different from getCurrentSelection which only gets selection from active editor. 15 | ---@return table content MCP-compliant response with content array 16 | local function handler(params) 17 | local selection_module_ok, selection_module = pcall(require, "claudecode.selection") 18 | if not selection_module_ok then 19 | error({ code = -32000, message = "Internal server error", data = "Failed to load selection module" }) 20 | end 21 | 22 | local selection = selection_module.get_latest_selection() 23 | 24 | if not selection then 25 | -- Return MCP-compliant format with JSON-stringified result 26 | return { 27 | content = { 28 | { 29 | type = "text", 30 | text = vim.json.encode({ 31 | success = false, 32 | message = "No selection available", 33 | }, { indent = 2 }), 34 | }, 35 | }, 36 | } 37 | end 38 | 39 | -- Return MCP-compliant format with JSON-stringified selection data 40 | return { 41 | content = { 42 | { 43 | type = "text", 44 | text = vim.json.encode(selection, { indent = 2 }), 45 | }, 46 | }, 47 | } 48 | end 49 | 50 | return { 51 | name = "getLatestSelection", 52 | schema = schema, 53 | handler = handler, 54 | } 55 | -------------------------------------------------------------------------------- /fixtures/netrw/lua/plugins/netrw-keymaps.lua: -------------------------------------------------------------------------------- 1 | -- Netrw keymaps setup 2 | return { 3 | { 4 | "netrw-keymaps", 5 | dir = vim.fn.stdpath("config"), 6 | name = "netrw-keymaps", 7 | config = function() 8 | -- Set up global keymaps 9 | vim.keymap.set("n", "e", function() 10 | if vim.bo.filetype == "netrw" then 11 | vim.cmd("bd") 12 | else 13 | vim.cmd("Explore") 14 | end 15 | end, { desc = "Toggle file explorer" }) 16 | 17 | vim.keymap.set("n", "E", "Vexplore", { desc = "Open file explorer (split)" }) 18 | 19 | -- Netrw-specific keymaps (active in netrw buffers only) 20 | vim.api.nvim_create_autocmd("FileType", { 21 | pattern = "netrw", 22 | callback = function() 23 | local buf = vim.api.nvim_get_current_buf() 24 | local opts = { buffer = buf } 25 | 26 | vim.keymap.set("n", "h", "-", vim.tbl_extend("force", opts, { desc = "Go up directory" })) 27 | vim.keymap.set("n", "l", "", vim.tbl_extend("force", opts, { desc = "Enter directory/open file" })) 28 | vim.keymap.set("n", ".", "gh", vim.tbl_extend("force", opts, { desc = "Toggle hidden files" })) 29 | vim.keymap.set("n", "P", "z", vim.tbl_extend("force", opts, { desc = "Close preview" })) 30 | vim.keymap.set("n", "dd", "D", vim.tbl_extend("force", opts, { desc = "Delete file/directory" })) 31 | vim.keymap.set("n", "r", "R", vim.tbl_extend("force", opts, { desc = "Rename file" })) 32 | vim.keymap.set("n", "n", "%", vim.tbl_extend("force", opts, { desc = "Create new file" })) 33 | vim.keymap.set("n", "N", "d", vim.tbl_extend("force", opts, { desc = "Create new directory" })) 34 | end, 35 | }) 36 | end, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /lua/claudecode/tools/check_document_dirty.lua: -------------------------------------------------------------------------------- 1 | ---Tool implementation for checking if a document is dirty. 2 | 3 | local schema = { 4 | description = "Check if a document has unsaved changes (is dirty)", 5 | inputSchema = { 6 | type = "object", 7 | properties = { 8 | filePath = { 9 | type = "string", 10 | description = "Path to the file to check", 11 | }, 12 | }, 13 | required = { "filePath" }, 14 | additionalProperties = false, 15 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 16 | }, 17 | } 18 | 19 | ---Handles the checkDocumentDirty tool invocation. 20 | ---Checks if the specified file (buffer) has unsaved changes. 21 | ---@param params table The input parameters for the tool 22 | ---@return table MCP-compliant response with dirty status 23 | local function handler(params) 24 | if not params.filePath then 25 | error({ code = -32602, message = "Invalid params", data = "Missing filePath parameter" }) 26 | end 27 | 28 | local bufnr = vim.fn.bufnr(params.filePath) 29 | 30 | if bufnr == -1 then 31 | -- Return success: false when document not open, matching VS Code behavior 32 | return { 33 | content = { 34 | { 35 | type = "text", 36 | text = vim.json.encode({ 37 | success = false, 38 | message = "Document not open: " .. params.filePath, 39 | }, { indent = 2 }), 40 | }, 41 | }, 42 | } 43 | end 44 | 45 | local is_dirty = vim.api.nvim_buf_get_option(bufnr, "modified") 46 | local is_untitled = vim.api.nvim_buf_get_name(bufnr) == "" 47 | 48 | -- Return MCP-compliant format with JSON-stringified result 49 | return { 50 | content = { 51 | { 52 | type = "text", 53 | text = vim.json.encode({ 54 | success = true, 55 | filePath = params.filePath, 56 | isDirty = is_dirty, 57 | isUntitled = is_untitled, 58 | }, { indent = 2 }), 59 | }, 60 | }, 61 | } 62 | end 63 | 64 | return { 65 | name = "checkDocumentDirty", 66 | schema = schema, 67 | handler = handler, 68 | } 69 | -------------------------------------------------------------------------------- /fixtures/oil/lua/plugins/oil-nvim.lua: -------------------------------------------------------------------------------- 1 | return { 2 | "stevearc/oil.nvim", 3 | ---@module 'oil' 4 | ---@type oil.SetupOpts 5 | opts = { 6 | default_file_explorer = true, 7 | columns = { 8 | "icon", 9 | "permissions", 10 | "size", 11 | "mtime", 12 | }, 13 | view_options = { 14 | show_hidden = false, 15 | }, 16 | float = { 17 | padding = 2, 18 | max_width = 90, 19 | max_height = 0, 20 | border = "rounded", 21 | win_options = { 22 | winblend = 0, 23 | }, 24 | }, 25 | }, 26 | -- Optional dependencies 27 | dependencies = { { "echasnovski/mini.icons", opts = {} } }, 28 | -- dependencies = { "nvim-tree/nvim-web-devicons" }, -- use if you prefer nvim-web-devicons 29 | -- Lazy loading is not recommended because it is very tricky to make it work correctly in all situations. 30 | lazy = false, 31 | config = function(_, opts) 32 | require("oil").setup(opts) 33 | 34 | -- Global keybindings for oil 35 | vim.keymap.set("n", "o", "Oil", { desc = "Open Oil (current dir)" }) 36 | vim.keymap.set("n", "O", "Oil --float", { desc = "Open Oil (floating)" }) 37 | vim.keymap.set("n", "-", "Oil", { desc = "Open parent directory" }) 38 | 39 | -- Oil-specific keybindings (only active in Oil buffers) 40 | vim.api.nvim_create_autocmd("FileType", { 41 | pattern = "oil", 42 | callback = function() 43 | vim.keymap.set("n", "", "Oil --float", { buffer = true, desc = "Open Oil float" }) 44 | vim.keymap.set("n", "g.", function() 45 | require("oil").toggle_hidden() 46 | end, { buffer = true, desc = "Toggle hidden files" }) 47 | vim.keymap.set("n", "", function() 48 | require("oil").set_columns({ "icon", "permissions", "size", "mtime" }) 49 | end, { buffer = true, desc = "Show detailed view" }) 50 | vim.keymap.set("n", "", function() 51 | require("oil").set_columns({ "icon" }) 52 | end, { buffer = true, desc = "Show simple view" }) 53 | end, 54 | }) 55 | end, 56 | } 57 | -------------------------------------------------------------------------------- /lua/claudecode/terminal/none.lua: -------------------------------------------------------------------------------- 1 | --- No-op terminal provider for Claude Code. 2 | --- Performs zero UI actions and never manages terminals inside Neovim. 3 | ---@module 'claudecode.terminal.none' 4 | 5 | ---@type ClaudeCodeTerminalProvider 6 | local M = {} 7 | 8 | ---Stored config (not used, but kept for parity with other providers) 9 | ---Setup the no-op provider 10 | ---@param term_config ClaudeCodeTerminalConfig 11 | function M.setup(term_config) 12 | -- intentionally no-op 13 | end 14 | 15 | ---Open terminal (no-op) 16 | ---@param cmd_string string 17 | ---@param env_table table 18 | ---@param effective_config ClaudeCodeTerminalConfig 19 | ---@param focus boolean|nil 20 | function M.open(cmd_string, env_table, effective_config, focus) 21 | -- intentionally no-op 22 | end 23 | 24 | ---Close terminal (no-op) 25 | function M.close() 26 | -- intentionally no-op 27 | end 28 | 29 | ---Simple toggle (no-op) 30 | ---@param cmd_string string 31 | ---@param env_table table 32 | ---@param effective_config ClaudeCodeTerminalConfig 33 | function M.simple_toggle(cmd_string, env_table, effective_config) 34 | -- intentionally no-op 35 | end 36 | 37 | ---Focus toggle (no-op) 38 | ---@param cmd_string string 39 | ---@param env_table table 40 | ---@param effective_config ClaudeCodeTerminalConfig 41 | function M.focus_toggle(cmd_string, env_table, effective_config) 42 | -- intentionally no-op 43 | end 44 | 45 | ---Legacy toggle (no-op) 46 | ---@param cmd_string string 47 | ---@param env_table table 48 | ---@param effective_config ClaudeCodeTerminalConfig 49 | function M.toggle(cmd_string, env_table, effective_config) 50 | -- intentionally no-op 51 | end 52 | 53 | ---Ensure visible (no-op) 54 | function M.ensure_visible() end 55 | 56 | ---Return active buffer number (always nil) 57 | ---@return number|nil 58 | function M.get_active_bufnr() 59 | return nil 60 | end 61 | 62 | ---Provider availability (always true; explicit opt-in required) 63 | ---@return boolean 64 | function M.is_available() 65 | return true 66 | end 67 | 68 | ---Testing hook (no state to return) 69 | ---@return table|nil 70 | function M._get_terminal_for_test() 71 | return nil 72 | end 73 | 74 | return M 75 | -------------------------------------------------------------------------------- /lua/claudecode/tools/get_workspace_folders.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for getting workspace folders. 2 | 3 | local schema = { 4 | description = "Get all workspace folders currently open in the IDE", 5 | inputSchema = { 6 | type = "object", 7 | additionalProperties = false, 8 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 9 | }, 10 | } 11 | 12 | ---Handles the getWorkspaceFolders tool invocation. 13 | ---Retrieves workspace folders, currently defaulting to CWD and attempting LSP integration. 14 | ---@return table MCP-compliant response with workspace folders data 15 | local function handler(params) 16 | local cwd = vim.fn.getcwd() 17 | 18 | -- TODO: Enhance integration with LSP workspace folders if available, 19 | -- similar to how it's done in claudecode.lockfile.get_workspace_folders. 20 | -- For now, this is a simplified version as per the original tool's direct implementation. 21 | 22 | local folders = { 23 | { 24 | name = vim.fn.fnamemodify(cwd, ":t"), 25 | uri = "file://" .. cwd, 26 | path = cwd, 27 | }, 28 | } 29 | 30 | -- A more complete version would replicate the logic from claudecode.lockfile: 31 | -- local lsp_folders = get_lsp_workspace_folders_logic_here() 32 | -- for _, folder_path in ipairs(lsp_folders) do 33 | -- local already_exists = false 34 | -- for _, existing_folder in ipairs(folders) do 35 | -- if existing_folder.path == folder_path then 36 | -- already_exists = true 37 | -- break 38 | -- end 39 | -- end 40 | -- if not already_exists then 41 | -- table.insert(folders, { 42 | -- name = vim.fn.fnamemodify(folder_path, ":t"), 43 | -- uri = "file://" .. folder_path, 44 | -- path = folder_path, 45 | -- }) 46 | -- end 47 | -- end 48 | 49 | -- Return MCP-compliant format with JSON-stringified workspace data 50 | return { 51 | content = { 52 | { 53 | type = "text", 54 | text = vim.json.encode({ 55 | success = true, 56 | folders = folders, 57 | rootPath = cwd, 58 | }, { indent = 2 }), 59 | }, 60 | }, 61 | } 62 | end 63 | 64 | return { 65 | name = "getWorkspaceFolders", 66 | schema = schema, 67 | handler = handler, 68 | } 69 | -------------------------------------------------------------------------------- /tests/unit/terminal/none_provider_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") 2 | require("tests.mocks.vim") 3 | 4 | describe("none terminal provider", function() 5 | local terminal 6 | 7 | local termopen_calls 8 | local jobstart_calls 9 | 10 | before_each(function() 11 | -- Prepare vim.fn helpers used by terminal module 12 | vim.fn = vim.fn or {} 13 | vim.fn.getcwd = function() 14 | return "/mock/cwd" 15 | end 16 | vim.fn.expand = function(val) 17 | return val 18 | end 19 | 20 | -- Spy-able termopen/jobstart that count invocations 21 | termopen_calls = 0 22 | jobstart_calls = 0 23 | vim.fn.termopen = function(...) 24 | termopen_calls = termopen_calls + 1 25 | return 1 26 | end 27 | vim.fn.jobstart = function(...) 28 | jobstart_calls = jobstart_calls + 1 29 | return 1 30 | end 31 | 32 | -- Minimal logger + server mocks 33 | package.loaded["claudecode.logger"] = { 34 | debug = function() end, 35 | warn = function() end, 36 | error = function() end, 37 | info = function() end, 38 | setup = function() end, 39 | } 40 | package.loaded["claudecode.server.init"] = { state = { port = 12345 } } 41 | 42 | -- Ensure fresh terminal module load 43 | package.loaded["claudecode.terminal"] = nil 44 | package.loaded["claudecode.terminal.none"] = nil 45 | package.loaded["claudecode.terminal.native"] = nil 46 | package.loaded["claudecode.terminal.snacks"] = nil 47 | 48 | terminal = require("claudecode.terminal") 49 | terminal.setup({ provider = "none" }, nil, {}) 50 | end) 51 | 52 | it("does not invoke any terminal APIs", function() 53 | -- Exercise all public actions 54 | terminal.open({}, "--help") 55 | terminal.simple_toggle({}, "--resume") 56 | terminal.focus_toggle({}, "--continue") 57 | terminal.ensure_visible({}, nil) 58 | terminal.toggle_open_no_focus({}, nil) 59 | terminal.close() 60 | 61 | -- Assert no terminal processes/windows were spawned 62 | assert.are.equal(0, termopen_calls) 63 | assert.are.equal(0, jobstart_calls) 64 | end) 65 | 66 | it("returns nil for active buffer", function() 67 | local bufnr = terminal.get_active_terminal_bufnr() 68 | assert.is_nil(bufnr) 69 | end) 70 | end) 71 | -------------------------------------------------------------------------------- /lua/claudecode/tools/save_document.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for saving a document. 2 | 3 | local schema = { 4 | description = "Save a document with unsaved changes", 5 | inputSchema = { 6 | type = "object", 7 | properties = { 8 | filePath = { 9 | type = "string", 10 | description = "Path to the file to save", 11 | }, 12 | }, 13 | required = { "filePath" }, 14 | additionalProperties = false, 15 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 16 | }, 17 | } 18 | 19 | ---Handles the saveDocument tool invocation. 20 | ---Saves the specified file (buffer). 21 | ---@param params table The input parameters for the tool 22 | ---@return table MCP-compliant response with save status 23 | local function handler(params) 24 | if not params.filePath then 25 | error({ 26 | code = -32602, 27 | message = "Invalid params", 28 | data = "Missing filePath parameter", 29 | }) 30 | end 31 | 32 | local bufnr = vim.fn.bufnr(params.filePath) 33 | 34 | if bufnr == -1 then 35 | -- Return failure when document not open, matching VS Code behavior 36 | return { 37 | content = { 38 | { 39 | type = "text", 40 | text = vim.json.encode({ 41 | success = false, 42 | message = "Document not open: " .. params.filePath, 43 | }, { indent = 2 }), 44 | }, 45 | }, 46 | } 47 | end 48 | 49 | local success, err = pcall(vim.api.nvim_buf_call, bufnr, function() 50 | vim.cmd("write") 51 | end) 52 | 53 | if not success then 54 | return { 55 | content = { 56 | { 57 | type = "text", 58 | text = vim.json.encode({ 59 | success = false, 60 | message = "Failed to save file: " .. tostring(err), 61 | filePath = params.filePath, 62 | }, { indent = 2 }), 63 | }, 64 | }, 65 | } 66 | end 67 | 68 | -- Return MCP-compliant format with JSON-stringified success result 69 | return { 70 | content = { 71 | { 72 | type = "text", 73 | text = vim.json.encode({ 74 | success = true, 75 | filePath = params.filePath, 76 | saved = true, 77 | message = "Document saved successfully", 78 | }, { indent = 2 }), 79 | }, 80 | }, 81 | } 82 | end 83 | 84 | return { 85 | name = "saveDocument", 86 | schema = schema, 87 | handler = handler, 88 | } 89 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Claude Code Neovim plugin development environment"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | treefmt-nix.url = "github:numtide/treefmt-nix"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, flake-utils, treefmt-nix, ... }: 11 | flake-utils.lib.eachDefaultSystem (system: 12 | let 13 | pkgs = import nixpkgs { 14 | inherit system; 15 | 16 | config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ 17 | "claude-code" 18 | ]; 19 | }; 20 | 21 | treefmt = treefmt-nix.lib.evalModule pkgs { 22 | projectRootFile = "flake.nix"; 23 | programs = { 24 | stylua.enable = true; 25 | nixpkgs-fmt.enable = true; 26 | prettier.enable = true; 27 | shfmt.enable = true; 28 | actionlint.enable = true; 29 | zizmor.enable = true; 30 | shellcheck.enable = true; 31 | }; 32 | settings.formatter.shellcheck.options = [ "--exclude=SC1091,SC2016" ]; 33 | settings.formatter.prettier.excludes = [ 34 | # Exclude lazy.nvim lock files as they are auto-generated 35 | # and will be reformatted by lazy on each package update 36 | "fixtures/*/lazy-lock.json" 37 | ]; 38 | }; 39 | 40 | # CI-specific packages (minimal set for testing and linting) 41 | ciPackages = with pkgs; [ 42 | lua5_1 43 | luajitPackages.luacheck 44 | luajitPackages.busted 45 | luajitPackages.luacov 46 | neovim 47 | treefmt.config.build.wrapper 48 | findutils 49 | ]; 50 | 51 | # Development packages (additional tools for development) 52 | devPackages = with pkgs; [ 53 | ast-grep 54 | luarocks 55 | gnumake 56 | websocat 57 | jq 58 | fzf 59 | # claude-code 60 | ]; 61 | in 62 | { 63 | # Format the source tree 64 | formatter = treefmt.config.build.wrapper; 65 | 66 | # Check formatting 67 | checks.formatting = treefmt.config.build.check self; 68 | 69 | devShells = { 70 | # Minimal CI environment 71 | ci = pkgs.mkShell { 72 | buildInputs = ciPackages; 73 | }; 74 | 75 | # Full development environment 76 | default = pkgs.mkShell { 77 | buildInputs = ciPackages ++ devPackages; 78 | }; 79 | }; 80 | } 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | 5 | - `lua/claudecode/`: Core plugin modules (`init.lua`, `config.lua`, `diff.lua`, `terminal/`, `server/`, `tools/`, `logger.lua`, etc.). 6 | - `plugin/`: Lightweight loader that guards startup and optional auto-setup. 7 | - `tests/`: Busted test suite (`unit/`, `integration/`, `helpers/`, `mocks/`). 8 | - `fixtures/`: Minimal Neovim configs for manual and integration testing. 9 | - `scripts/`: Development helpers; `dev-config.lua` aids local testing. 10 | 11 | ## Build, Test, and Development Commands 12 | 13 | - `make check`: Syntax checks + `luacheck` on `lua/` and `tests/`. 14 | - `make format`: Format with StyLua (via Nix `nix fmt` in this repo). 15 | - `make test`: Run Busted tests with coverage (outputs `luacov.stats.out`). 16 | - `make clean`: Remove coverage artifacts. 17 | Examples: 18 | - Run all tests: `make test` 19 | - Run one file: `busted -v tests/unit/terminal_spec.lua` 20 | 21 | ## Coding Style & Naming Conventions 22 | 23 | - Lua: 2‑space indent, 120‑column width, double quotes preferred. 24 | - Formatting: StyLua configured in `.stylua.toml` (require-sort enabled). 25 | - Linting: `luacheck` with settings in `.luacheckrc` (uses `luajit+busted` std). 26 | - Modules/files: lower_snake_case; return a module table `M` with documented functions (EmmyLua annotations encouraged). 27 | - Avoid one-letter names; prefer explicit, testable functions. 28 | 29 | ## Testing Guidelines 30 | 31 | - Frameworks: Busted + Luassert; Plenary used where Neovim APIs are needed. 32 | - File naming: `*_spec.lua` or `*_test.lua` under `tests/unit` or `tests/integration`. 33 | - Mocks/helpers: place in `tests/mocks` and `tests/helpers`; prefer pure‑Lua mocks. 34 | - Coverage: keep high signal; exercise server, tools, diff, and terminal paths. 35 | - Quick tip: prefer `make test` (it sets `LUA_PATH` and coverage flags). 36 | 37 | ## Commit & Pull Request Guidelines 38 | 39 | - Commits: Use Conventional Commits (e.g., `feat:`, `fix:`, `docs:`, `test:`, `chore:`). Keep messages imperative and scoped. 40 | - PRs: include a clear description, linked issues, repro steps, and tests. Update docs (`README.md`, `ARCHITECTURE.md`, or `DEVELOPMENT.md`) when behavior changes. 41 | - Pre-flight: run `make format`, `make check`, and `make test`. Attach screenshots or terminal recordings for UX-visible changes (diff flows, terminal behavior). 42 | 43 | ## Security & Configuration Tips 44 | 45 | - Do not commit secrets or local paths; prefer environment variables. The plugin honors `CLAUDE_CONFIG_DIR` for lock files. 46 | - Local CLI paths (e.g., `opts.terminal_cmd`) should be configured in user config, not hardcoded in repo files. 47 | -------------------------------------------------------------------------------- /scripts/test_opendiff_simple.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Simple openDiff test using websocat (if available) or curl 4 | # This script sends the same tool call that Claude Code would send 5 | 6 | set -e 7 | 8 | echo "🧪 Testing openDiff tool behavior..." 9 | 10 | # Find the port from lock file 11 | LOCK_DIR="$HOME/.claude/ide" 12 | if [[ ! -d $LOCK_DIR ]]; then 13 | echo "❌ Lock directory not found: $LOCK_DIR" 14 | echo " Make sure Neovim with claudecode.nvim is running" 15 | exit 1 16 | fi 17 | 18 | LOCK_FILE=$(find "$LOCK_DIR" -name "*.lock" -type f 2>/dev/null | head -1) 19 | if [[ -z $LOCK_FILE ]]; then 20 | echo "❌ No lock files found in $LOCK_DIR" 21 | echo " Make sure Neovim with claudecode.nvim is running" 22 | exit 1 23 | fi 24 | 25 | PORT=$(basename "$LOCK_FILE" .lock) 26 | echo "✅ Found port: $PORT" 27 | 28 | # Read README.md 29 | if [[ ! -f "README.md" ]]; then 30 | echo "❌ README.md not found - run this script from project root" 31 | exit 1 32 | fi 33 | 34 | echo "✅ Found README.md" 35 | 36 | # Create modified content (add license section) 37 | NEW_CONTENT=$(cat README.md && echo -e "\n## License\n\n[MIT](LICENSE)") 38 | 39 | # Get absolute path 40 | ABS_PATH="$(pwd)/README.md" 41 | 42 | # Create JSON-RPC message 43 | JSON_MESSAGE=$( 44 | cat </dev/null 2>&1; then 66 | echo "Using websocat..." 67 | echo "$JSON_MESSAGE" | timeout 30s websocat "ws://127.0.0.1:$PORT" || { 68 | if [[ $? -eq 124 ]]; then 69 | echo "✅ Tool blocked for 30s (good behavior!)" 70 | echo "👉 Check Neovim for the diff view" 71 | else 72 | echo "❌ Connection failed" 73 | fi 74 | } 75 | elif command -v wscat >/dev/null 2>&1; then 76 | echo "Using wscat..." 77 | echo "$JSON_MESSAGE" | timeout 30s wscat -c "ws://127.0.0.1:$PORT" || { 78 | if [[ $? -eq 124 ]]; then 79 | echo "✅ Tool blocked for 30s (good behavior!)" 80 | echo "👉 Check Neovim for the diff view" 81 | else 82 | echo "❌ Connection failed" 83 | fi 84 | } 85 | else 86 | echo "❌ No WebSocket client found (websocat or wscat needed)" 87 | echo " Install with: brew install websocat" 88 | echo " Or: npm install -g wscat" 89 | exit 1 90 | fi 91 | 92 | echo "✅ Test completed" 93 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1753694789, 24 | "narHash": "sha256-cKgvtz6fKuK1Xr5LQW/zOUiAC0oSQoA9nOISB0pJZqM=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "dc9637876d0dcc8c9e5e22986b857632effeb727", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs_2": { 38 | "locked": { 39 | "lastModified": 1747958103, 40 | "narHash": "sha256-qmmFCrfBwSHoWw7cVK4Aj+fns+c54EBP8cGqp/yK410=", 41 | "owner": "nixos", 42 | "repo": "nixpkgs", 43 | "rev": "fe51d34885f7b5e3e7b59572796e1bcb427eccb1", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "nixos", 48 | "ref": "nixpkgs-unstable", 49 | "repo": "nixpkgs", 50 | "type": "github" 51 | } 52 | }, 53 | "root": { 54 | "inputs": { 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "treefmt-nix": "treefmt-nix" 58 | } 59 | }, 60 | "systems": { 61 | "locked": { 62 | "lastModified": 1681028828, 63 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 64 | "owner": "nix-systems", 65 | "repo": "default", 66 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 67 | "type": "github" 68 | }, 69 | "original": { 70 | "owner": "nix-systems", 71 | "repo": "default", 72 | "type": "github" 73 | } 74 | }, 75 | "treefmt-nix": { 76 | "inputs": { 77 | "nixpkgs": "nixpkgs_2" 78 | }, 79 | "locked": { 80 | "lastModified": 1753772294, 81 | "narHash": "sha256-8rkd13WfClfZUBIYpX5dvG3O9V9w3K9FPQ9rY14VtBE=", 82 | "owner": "numtide", 83 | "repo": "treefmt-nix", 84 | "rev": "6b9214fffbcf3f1e608efa15044431651635ca83", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "numtide", 89 | "repo": "treefmt-nix", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /tests/minimal_init.lua: -------------------------------------------------------------------------------- 1 | -- Minimal Neovim configuration for tests 2 | 3 | -- Set up package path 4 | local package_root = vim.fn.stdpath("data") .. "/site/pack/vendor/start/" 5 | local install_path = package_root .. "plenary.nvim" 6 | 7 | if vim.fn.empty(vim.fn.glob(install_path)) > 0 then 8 | vim.fn.system({ 9 | "git", 10 | "clone", 11 | "--depth", 12 | "1", 13 | "https://github.com/nvim-lua/plenary.nvim", 14 | install_path, 15 | }) 16 | vim.cmd([[packadd plenary.nvim]]) 17 | end 18 | 19 | -- Add package paths for development 20 | vim.opt.runtimepath:append(vim.fn.expand("$HOME/.local/share/nvim/site/pack/vendor/start/plenary.nvim")) 21 | -- Add current working directory to runtime path for development 22 | vim.opt.runtimepath:prepend(vim.fn.getcwd()) 23 | 24 | -- Set up test environment 25 | vim.g.mapleader = " " 26 | vim.g.maplocalleader = " " 27 | vim.opt.termguicolors = true 28 | vim.opt.timeoutlen = 300 29 | vim.opt.updatetime = 250 30 | 31 | -- Disable some built-in plugins 32 | local disabled_built_ins = { 33 | "gzip", 34 | "matchit", 35 | "matchparen", 36 | "netrwPlugin", 37 | "tarPlugin", 38 | "tohtml", 39 | "tutor", 40 | "zipPlugin", 41 | } 42 | 43 | for _, plugin in pairs(disabled_built_ins) do 44 | vim.g["loaded_" .. plugin] = 1 45 | end 46 | 47 | -- Check for claudecode-specific tests by examining command line or environment 48 | local should_load = false 49 | 50 | -- Method 1: Check command line arguments for specific test files 51 | for _, arg in ipairs(vim.v.argv) do 52 | if arg:match("command_args_spec") or arg:match("mcp_tools_spec") then 53 | should_load = true 54 | break 55 | end 56 | end 57 | 58 | -- Method 2: Check if CLAUDECODE_INTEGRATION_TEST env var is set 59 | if not should_load and os.getenv("CLAUDECODE_INTEGRATION_TEST") == "true" then 60 | should_load = true 61 | end 62 | 63 | if not vim.g.loaded_claudecode and should_load then 64 | require("claudecode").setup({ 65 | auto_start = false, 66 | log_level = "trace", -- More verbose for tests 67 | }) 68 | end 69 | 70 | -- Global cleanup function for plenary test harness 71 | _G.claudecode_test_cleanup = function() 72 | -- Clear global deferred responses 73 | if _G.claude_deferred_responses then 74 | _G.claude_deferred_responses = {} 75 | end 76 | 77 | -- Stop claudecode if running 78 | local ok, claudecode = pcall(require, "claudecode") 79 | if ok and claudecode.state and claudecode.state.server then 80 | local selection_ok, selection = pcall(require, "claudecode.selection") 81 | if selection_ok and selection.disable then 82 | selection.disable() 83 | end 84 | 85 | if claudecode.stop then 86 | claudecode.stop() 87 | end 88 | end 89 | end 90 | 91 | -- Auto-cleanup when using plenary test harness 92 | if vim.env.PLENARY_TEST_HARNESS then 93 | vim.api.nvim_create_autocmd("VimLeavePre", { 94 | callback = function() 95 | _G.claudecode_test_cleanup() 96 | end, 97 | }) 98 | end 99 | -------------------------------------------------------------------------------- /.claude/hooks/format.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Claude Code Hook: Format Files 4 | # Triggers after Claude edits/writes files and runs nix fmt 5 | # 6 | # Environment variables provided by Claude Code: 7 | # - CLAUDE_PROJECT_DIR: Path to the project directory 8 | # - CLAUDE_TOOL_NAME: Name of the tool that was executed 9 | # - CLAUDE_TOOL_ARGS: JSON string containing tool arguments 10 | 11 | set -euo pipefail 12 | 13 | # Colors for output 14 | RED='\033[0;31m' 15 | GREEN='\033[0;32m' 16 | YELLOW='\033[1;33m' 17 | NC='\033[0m' # No Color 18 | 19 | # Log function 20 | log() { 21 | echo -e "[$(date '+%H:%M:%S')] $1" >&2 22 | } 23 | 24 | # Parse tool arguments to get the file path 25 | get_file_path() { 26 | # Read hook input from stdin 27 | local hook_input 28 | if [ -t 0 ]; then 29 | # No stdin input available 30 | log "DEBUG: No stdin input available" 31 | return 32 | fi 33 | 34 | hook_input=$(cat) 35 | log "DEBUG: Hook input = $hook_input" 36 | 37 | # Try to extract file_path from tool_input 38 | local file_path 39 | file_path=$(echo "$hook_input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') 40 | 41 | if [ -n "$file_path" ]; then 42 | echo "$file_path" 43 | return 44 | fi 45 | 46 | # Try extracting any file path from the input 47 | local any_file_path 48 | any_file_path=$(echo "$hook_input" | grep -o '"[^"]*\.[^"]*"' | sed 's/"//g' | head -1) 49 | 50 | if [ -n "$any_file_path" ]; then 51 | echo "$any_file_path" 52 | return 53 | fi 54 | 55 | log "DEBUG: Could not extract file path from hook input" 56 | } 57 | 58 | # Main logic 59 | main() { 60 | log "${YELLOW}Claude Code Hook: File Formatter${NC}" 61 | 62 | # Get the file path from tool arguments 63 | FILE_PATH=$(get_file_path) 64 | 65 | if [ -z "$FILE_PATH" ]; then 66 | log "${RED}Error: Could not determine file path from tool arguments${NC}" 67 | exit 1 68 | fi 69 | 70 | log "Tool: ${CLAUDE_TOOL_NAME:-unknown}, File: $FILE_PATH" 71 | 72 | # Check if file exists 73 | if [ ! -f "$FILE_PATH" ]; then 74 | log "${RED}Error: File does not exist: $FILE_PATH${NC}" 75 | exit 1 76 | fi 77 | 78 | log "${YELLOW}Formatting file with nix fmt...${NC}" 79 | 80 | # Change to project directory 81 | cd "${CLAUDE_PROJECT_DIR}" 82 | 83 | # Run nix fmt on the file 84 | if nix fmt "$FILE_PATH" 2>/dev/null; then 85 | log "${GREEN}✓ Successfully formatted: $FILE_PATH${NC}" 86 | exit 0 87 | else 88 | EXIT_CODE=$? 89 | log "${RED}✗ nix fmt failed with exit code $EXIT_CODE${NC}" 90 | log "${RED}This indicates the file has formatting issues that need manual attention${NC}" 91 | 92 | # Don't fail the hook - just warn about formatting issues 93 | # This allows Claude's operation to continue while alerting about format problems 94 | log "${YELLOW}Continuing with Claude's operation, but please fix formatting issues${NC}" 95 | exit 0 96 | fi 97 | } 98 | 99 | # Run main function 100 | main "$@" 101 | -------------------------------------------------------------------------------- /lua/claudecode/tools/open_diff.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for opening a diff view. 2 | 3 | local schema = { 4 | description = "Open a diff view comparing old file content with new file content", 5 | inputSchema = { 6 | type = "object", 7 | properties = { 8 | old_file_path = { 9 | type = "string", 10 | description = "Path to the old file to compare", 11 | }, 12 | new_file_path = { 13 | type = "string", 14 | description = "Path to the new file to compare", 15 | }, 16 | new_file_contents = { 17 | type = "string", 18 | description = "Contents for the new file version", 19 | }, 20 | tab_name = { 21 | type = "string", 22 | description = "Name for the diff tab/view", 23 | }, 24 | }, 25 | required = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" }, 26 | additionalProperties = false, 27 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 28 | }, 29 | } 30 | 31 | ---Handles the openDiff tool invocation with MCP compliance. 32 | ---Opens a diff view and blocks until user interaction (save/close). 33 | ---Returns MCP-compliant response with content array format. 34 | ---@param params table The input parameters for the tool 35 | ---@return table response MCP-compliant response with content array 36 | local function handler(params) 37 | -- Validate required parameters 38 | local required_params = { "old_file_path", "new_file_path", "new_file_contents", "tab_name" } 39 | for _, param_name in ipairs(required_params) do 40 | if not params[param_name] then 41 | error({ 42 | code = -32602, -- Invalid params 43 | message = "Invalid params", 44 | data = "Missing required parameter: " .. param_name, 45 | }) 46 | end 47 | end 48 | 49 | -- Ensure we're running in a coroutine context for blocking operation 50 | local co, is_main = coroutine.running() 51 | if not co or is_main then 52 | error({ 53 | code = -32000, 54 | message = "Internal server error", 55 | data = "openDiff must run in coroutine context", 56 | }) 57 | end 58 | 59 | local diff_module_ok, diff_module = pcall(require, "claudecode.diff") 60 | if not diff_module_ok then 61 | error({ code = -32000, message = "Internal server error", data = "Failed to load diff module" }) 62 | end 63 | 64 | -- Use the new blocking diff operation 65 | local success, result = pcall( 66 | diff_module.open_diff_blocking, 67 | params.old_file_path, 68 | params.new_file_path, 69 | params.new_file_contents, 70 | params.tab_name 71 | ) 72 | 73 | if not success then 74 | -- Check if this is already a structured error 75 | if type(result) == "table" and result.code then 76 | error(result) 77 | else 78 | error({ 79 | code = -32000, -- Generic tool error 80 | message = "Error opening blocking diff", 81 | data = tostring(result), 82 | }) 83 | end 84 | end 85 | 86 | -- result should already be MCP-compliant with content array format 87 | return result 88 | end 89 | 90 | return { 91 | name = "openDiff", 92 | schema = schema, 93 | handler = handler, 94 | requires_coroutine = true, -- This tool needs coroutine context for blocking behavior 95 | } 96 | -------------------------------------------------------------------------------- /lua/claudecode/cwd.lua: -------------------------------------------------------------------------------- 1 | --- Working directory resolution helpers for ClaudeCode.nvim 2 | ---@module 'claudecode.cwd' 3 | 4 | local M = {} 5 | 6 | ---Normalize and validate a directory path 7 | ---@param dir string|nil 8 | ---@return string|nil 9 | local function normalize_dir(dir) 10 | if type(dir) ~= "string" or dir == "" then 11 | return nil 12 | end 13 | -- Expand ~ and similar 14 | local expanded = vim.fn.expand(dir) 15 | local isdir = 1 16 | if vim.fn.isdirectory then 17 | isdir = vim.fn.isdirectory(expanded) 18 | end 19 | if isdir == 1 then 20 | return expanded 21 | end 22 | return nil 23 | end 24 | 25 | ---Find the git repository root starting from a directory 26 | ---@param start_dir string|nil 27 | ---@return string|nil 28 | function M.git_root(start_dir) 29 | start_dir = normalize_dir(start_dir) 30 | if not start_dir then 31 | return nil 32 | end 33 | 34 | -- Prefer running without shell by passing a list 35 | local result 36 | if vim.fn.systemlist then 37 | local ok, _ = pcall(function() 38 | local _ = vim.fn.systemlist({ "git", "-C", start_dir, "rev-parse", "--show-toplevel" }) 39 | end) 40 | if ok then 41 | result = vim.fn.systemlist({ "git", "-C", start_dir, "rev-parse", "--show-toplevel" }) 42 | else 43 | -- Fallback to string command if needed 44 | local cmd = "git -C " .. vim.fn.shellescape(start_dir) .. " rev-parse --show-toplevel" 45 | result = vim.fn.systemlist(cmd) 46 | end 47 | end 48 | 49 | if vim.v.shell_error == 0 and result and #result > 0 then 50 | local root = normalize_dir(result[1]) 51 | if root then 52 | return root 53 | end 54 | end 55 | 56 | -- Fallback: search for .git directory upward 57 | if vim.fn.finddir then 58 | local git_dir = vim.fn.finddir(".git", start_dir .. ";") 59 | if type(git_dir) == "string" and git_dir ~= "" then 60 | local parent = vim.fn.fnamemodify(git_dir, ":h") 61 | return normalize_dir(parent) 62 | end 63 | end 64 | 65 | return nil 66 | end 67 | 68 | ---Resolve the effective working directory based on terminal config and context 69 | ---@param term_cfg ClaudeCodeTerminalConfig 70 | ---@param ctx ClaudeCodeCwdContext 71 | ---@return string|nil 72 | function M.resolve(term_cfg, ctx) 73 | if type(term_cfg) ~= "table" then 74 | return nil 75 | end 76 | 77 | -- 1) Custom provider takes precedence 78 | local provider = term_cfg.cwd_provider 79 | local provider_type = type(provider) 80 | if provider_type == "function" then 81 | local ok, res = pcall(provider, ctx) 82 | if ok then 83 | local p = normalize_dir(res) 84 | if p then 85 | return p 86 | end 87 | end 88 | end 89 | 90 | -- 2) Static cwd 91 | local static_cwd = normalize_dir(term_cfg.cwd) 92 | if static_cwd then 93 | return static_cwd 94 | end 95 | 96 | -- 3) Git repository root 97 | if term_cfg.git_repo_cwd then 98 | local start_dir = ctx and (ctx.file_dir or ctx.cwd) or vim.fn.getcwd() 99 | local root = M.git_root(start_dir) 100 | if root then 101 | return root 102 | end 103 | end 104 | 105 | -- 4) No override 106 | return nil 107 | end 108 | 109 | return M 110 | -------------------------------------------------------------------------------- /tests/unit/tools/get_workspace_folders_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") -- Ensure test helpers are loaded 2 | 3 | describe("Tool: get_workspace_folders", function() 4 | local get_workspace_folders_handler 5 | 6 | before_each(function() 7 | package.loaded["claudecode.tools.get_workspace_folders"] = nil 8 | get_workspace_folders_handler = require("claudecode.tools.get_workspace_folders").handler 9 | 10 | _G.vim = _G.vim or {} 11 | _G.vim.fn = _G.vim.fn or {} 12 | _G.vim.json = _G.vim.json or {} 13 | 14 | -- Mock vim.json.encode 15 | _G.vim.json.encode = spy.new(function(data, opts) 16 | return require("tests.busted_setup").json_encode(data) 17 | end) 18 | 19 | -- Default mocks 20 | _G.vim.fn.getcwd = spy.new(function() 21 | return "/mock/project/root" 22 | end) 23 | _G.vim.fn.fnamemodify = spy.new(function(path, mod) 24 | if mod == ":t" then 25 | local parts = {} 26 | for part in string.gmatch(path, "[^/]+") do 27 | table.insert(parts, part) 28 | end 29 | return parts[#parts] or "" 30 | end 31 | return path 32 | end) 33 | end) 34 | 35 | after_each(function() 36 | package.loaded["claudecode.tools.get_workspace_folders"] = nil 37 | _G.vim.fn.getcwd = nil 38 | _G.vim.fn.fnamemodify = nil 39 | _G.vim.json.encode = nil 40 | end) 41 | 42 | it("should return the current working directory as the only workspace folder", function() 43 | local success, result = pcall(get_workspace_folders_handler, {}) 44 | expect(success).to_be_true() 45 | expect(result).to_be_table() 46 | expect(result.content).to_be_table() 47 | expect(result.content[1]).to_be_table() 48 | expect(result.content[1].type).to_be("text") 49 | 50 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 51 | expect(parsed_result.success).to_be_true() 52 | expect(parsed_result.folders).to_be_table() 53 | expect(#parsed_result.folders).to_be(1) 54 | expect(parsed_result.rootPath).to_be("/mock/project/root") 55 | 56 | local folder = parsed_result.folders[1] 57 | expect(folder.name).to_be("root") 58 | expect(folder.uri).to_be("file:///mock/project/root") 59 | expect(folder.path).to_be("/mock/project/root") 60 | 61 | assert.spy(_G.vim.fn.getcwd).was_called() 62 | assert.spy(_G.vim.fn.fnamemodify).was_called_with("/mock/project/root", ":t") 63 | end) 64 | 65 | it("should handle different CWD paths correctly", function() 66 | _G.vim.fn.getcwd = spy.new(function() 67 | return "/another/path/project_name" 68 | end) 69 | local success, result = pcall(get_workspace_folders_handler, {}) 70 | expect(success).to_be_true() 71 | expect(result.content).to_be_table() 72 | 73 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 74 | expect(#parsed_result.folders).to_be(1) 75 | local folder = parsed_result.folders[1] 76 | expect(folder.name).to_be("project_name") 77 | expect(folder.uri).to_be("file:///another/path/project_name") 78 | expect(folder.path).to_be("/another/path/project_name") 79 | end) 80 | 81 | -- TODO: Add tests when LSP workspace folder integration is implemented in the tool. 82 | -- This would involve mocking vim.lsp.get_clients() and its return structure. 83 | end) 84 | -------------------------------------------------------------------------------- /scripts/run_integration_tests_individually.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to run integration tests individually to avoid plenary test_directory hanging 4 | # Each test file is run separately with test_file 5 | 6 | set -e 7 | 8 | echo "=== Running Integration Tests Individually ===" 9 | 10 | # Track overall results 11 | TOTAL_SUCCESS=0 12 | TOTAL_FAILED=0 13 | TOTAL_ERRORS=0 14 | FAILED_FILES=() 15 | 16 | # Function to run a single test file 17 | run_test_file() { 18 | local test_file=$1 19 | local basename 20 | basename=$(basename "$test_file") 21 | 22 | echo "" 23 | echo "Running: $basename" 24 | 25 | # Create a temporary file for output 26 | local temp_output 27 | temp_output=$(mktemp) 28 | 29 | # Run the test with timeout 30 | if timeout 30s nix develop .#ci -c nvim --headless -u tests/minimal_init.lua \ 31 | -c "lua require('plenary.test_harness').test_file('$test_file', {minimal_init = 'tests/minimal_init.lua'})" \ 32 | 2>&1 | tee "$temp_output"; then 33 | EXIT_CODE=0 34 | else 35 | EXIT_CODE=$? 36 | fi 37 | 38 | # Parse results from output 39 | local clean_output 40 | clean_output=$(sed 's/\x1b\[[0-9;]*m//g' "$temp_output") 41 | local success_count 42 | success_count=$(echo "$clean_output" | grep -c "Success" || true) 43 | local failed_lines 44 | failed_lines=$(echo "$clean_output" | grep "Failed :" || echo "Failed : 0") 45 | local failed_count 46 | failed_count=$(echo "$failed_lines" | tail -1 | awk '{print $3}' || echo "0") 47 | local error_lines 48 | error_lines=$(echo "$clean_output" | grep "Errors :" || echo "Errors : 0") 49 | local error_count 50 | error_count=$(echo "$error_lines" | tail -1 | awk '{print $3}' || echo "0") 51 | 52 | # Update totals 53 | TOTAL_SUCCESS=$((TOTAL_SUCCESS + success_count)) 54 | TOTAL_FAILED=$((TOTAL_FAILED + failed_count)) 55 | TOTAL_ERRORS=$((TOTAL_ERRORS + error_count)) 56 | 57 | # Check if test failed 58 | if [[ $failed_count -gt 0 ]] || [[ $error_count -gt 0 ]] || { [[ $EXIT_CODE -ne 0 ]] && [[ $EXIT_CODE -ne 124 ]] && [[ $EXIT_CODE -ne 143 ]]; }; then 59 | FAILED_FILES+=("$basename") 60 | fi 61 | 62 | # Cleanup 63 | rm -f "$temp_output" 64 | } 65 | 66 | # Run each test file, skipping command_args_spec.lua which is known to hang 67 | for test_file in tests/integration/*_spec.lua; do 68 | if [[ $test_file == *"command_args_spec.lua" ]]; then 69 | echo "" 70 | echo "Skipping: $(basename "$test_file") (known to hang in CI)" 71 | continue 72 | fi 73 | 74 | run_test_file "$test_file" 75 | done 76 | 77 | # Summary 78 | echo "" 79 | echo "=========================================" 80 | echo "Integration Test Summary" 81 | echo "=========================================" 82 | echo "Total Success: $TOTAL_SUCCESS" 83 | echo "Total Failed: $TOTAL_FAILED" 84 | echo "Total Errors: $TOTAL_ERRORS" 85 | 86 | if [[ ${#FAILED_FILES[@]} -gt 0 ]]; then 87 | echo "" 88 | echo "Failed test files:" 89 | for file in "${FAILED_FILES[@]}"; do 90 | echo " - $file" 91 | done 92 | fi 93 | 94 | # Exit with appropriate code 95 | if [[ $TOTAL_FAILED -eq 0 ]] && [[ $TOTAL_ERRORS -eq 0 ]]; then 96 | echo "" 97 | echo "✅ All integration tests passed!" 98 | exit 0 99 | else 100 | echo "" 101 | echo "❌ Some integration tests failed!" 102 | exit 1 103 | fi 104 | -------------------------------------------------------------------------------- /lua/claudecode/tools/close_all_diff_tabs.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for closing all diff tabs. 2 | 3 | local schema = { 4 | description = "Close all diff tabs in the editor", 5 | inputSchema = { 6 | type = "object", 7 | additionalProperties = false, 8 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 9 | }, 10 | } 11 | 12 | ---Handles the closeAllDiffTabs tool invocation. 13 | ---Closes all diff tabs/windows in the editor. 14 | ---@return table response MCP-compliant response with content array indicating number of closed tabs. 15 | local function handler(params) 16 | local closed_count = 0 17 | 18 | -- Get all windows 19 | local windows = vim.api.nvim_list_wins() 20 | local windows_to_close = {} -- Use set to avoid duplicates 21 | 22 | for _, win in ipairs(windows) do 23 | local buf = vim.api.nvim_win_get_buf(win) 24 | local buftype = vim.api.nvim_buf_get_option(buf, "buftype") 25 | local diff_mode = vim.api.nvim_win_get_option(win, "diff") 26 | local should_close = false 27 | 28 | -- Check if this is a diff window 29 | if diff_mode then 30 | should_close = true 31 | end 32 | 33 | -- Also check for diff-related buffer names or types 34 | local buf_name = vim.api.nvim_buf_get_name(buf) 35 | if buf_name:match("%.diff$") or buf_name:match("diff://") then 36 | should_close = true 37 | end 38 | 39 | -- Check for special diff buffer types 40 | if buftype == "nofile" and buf_name:match("^fugitive://") then 41 | should_close = true 42 | end 43 | 44 | -- Add to close set only once (prevents duplicates) 45 | if should_close then 46 | windows_to_close[win] = true 47 | end 48 | end 49 | 50 | -- Close the identified diff windows 51 | for win, _ in pairs(windows_to_close) do 52 | if vim.api.nvim_win_is_valid(win) then 53 | local success = pcall(vim.api.nvim_win_close, win, false) 54 | if success then 55 | closed_count = closed_count + 1 56 | end 57 | end 58 | end 59 | 60 | -- Also check for buffers that might be diff-related but not currently in windows 61 | local buffers = vim.api.nvim_list_bufs() 62 | for _, buf in ipairs(buffers) do 63 | if vim.api.nvim_buf_is_loaded(buf) then 64 | local buf_name = vim.api.nvim_buf_get_name(buf) 65 | local buftype = vim.api.nvim_buf_get_option(buf, "buftype") 66 | 67 | -- Check for diff-related buffers 68 | if 69 | buf_name:match("%.diff$") 70 | or buf_name:match("diff://") 71 | or (buftype == "nofile" and buf_name:match("^fugitive://")) 72 | then 73 | -- Delete the buffer if it's not in any window 74 | local buf_windows = vim.fn.win_findbuf(buf) 75 | if #buf_windows == 0 then 76 | local success = pcall(vim.api.nvim_buf_delete, buf, { force = true }) 77 | if success then 78 | closed_count = closed_count + 1 79 | end 80 | end 81 | end 82 | end 83 | end 84 | 85 | -- Return MCP-compliant format matching VS Code extension 86 | return { 87 | content = { 88 | { 89 | type = "text", 90 | text = "CLOSED_" .. closed_count .. "_DIFF_TABS", 91 | }, 92 | }, 93 | } 94 | end 95 | 96 | return { 97 | name = "closeAllDiffTabs", 98 | schema = schema, 99 | handler = handler, 100 | } 101 | -------------------------------------------------------------------------------- /lua/claudecode/tools/get_current_selection.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for getting the current selection. 2 | 3 | local schema = { 4 | description = "Get the current text selection in the editor", 5 | inputSchema = { 6 | type = "object", 7 | additionalProperties = false, 8 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 9 | }, 10 | } 11 | 12 | ---Helper function to safely encode data as JSON with error handling. 13 | ---@param data table The data to encode as JSON 14 | ---@param error_context string A description of what failed for error messages 15 | ---@return string The JSON-encoded string 16 | local function safe_json_encode(data, error_context) 17 | local ok, encoded = pcall(vim.json.encode, data, { indent = 2 }) 18 | if not ok then 19 | error({ 20 | code = -32000, 21 | message = "Internal server error", 22 | data = "Failed to encode " .. error_context .. ": " .. tostring(encoded), 23 | }) 24 | end 25 | return encoded 26 | end 27 | 28 | ---Handles the getCurrentSelection tool invocation. 29 | ---Gets the current text selection in the editor. 30 | ---@return table response MCP-compliant response with selection data. 31 | local function handler(params) 32 | local selection_module_ok, selection_module = pcall(require, "claudecode.selection") 33 | if not selection_module_ok then 34 | error({ code = -32000, message = "Internal server error", data = "Failed to load selection module" }) 35 | end 36 | 37 | local selection = selection_module.get_latest_selection() 38 | 39 | if not selection then 40 | -- Check if there's an active editor/buffer 41 | local current_buf = vim.api.nvim_get_current_buf() 42 | local buf_name = vim.api.nvim_buf_get_name(current_buf) 43 | 44 | if not buf_name or buf_name == "" then 45 | -- No active editor case - match VS Code format 46 | local no_editor_response = { 47 | success = false, 48 | message = "No active editor found", 49 | } 50 | 51 | return { 52 | content = { 53 | { 54 | type = "text", 55 | text = safe_json_encode(no_editor_response, "no editor response"), 56 | }, 57 | }, 58 | } 59 | end 60 | 61 | -- Valid buffer but no selection - return cursor position with success field 62 | local empty_selection = { 63 | success = true, 64 | text = "", 65 | filePath = buf_name, 66 | fileUrl = "file://" .. buf_name, 67 | selection = { 68 | start = { line = 0, character = 0 }, 69 | ["end"] = { line = 0, character = 0 }, 70 | isEmpty = true, 71 | }, 72 | } 73 | 74 | -- Return MCP-compliant format with JSON-stringified empty selection 75 | return { 76 | content = { 77 | { 78 | type = "text", 79 | text = safe_json_encode(empty_selection, "empty selection"), 80 | }, 81 | }, 82 | } 83 | end 84 | 85 | -- Add success field to existing selection data 86 | local selection_with_success = vim.tbl_extend("force", selection, { success = true }) 87 | 88 | -- Return MCP-compliant format with JSON-stringified selection data 89 | return { 90 | content = { 91 | { 92 | type = "text", 93 | text = safe_json_encode(selection_with_success, "selection"), 94 | }, 95 | }, 96 | } 97 | end 98 | 99 | return { 100 | name = "getCurrentSelection", 101 | schema = schema, 102 | handler = handler, 103 | } 104 | -------------------------------------------------------------------------------- /scripts/manual_test_helper.lua: -------------------------------------------------------------------------------- 1 | -- Manual test helper for openDiff 2 | -- Run this in Neovim with :luafile scripts/manual_test_helper.lua 3 | 4 | local function test_opendiff_directly() 5 | print("🧪 Testing openDiff tool directly...") 6 | 7 | -- Use the actual README.md file like the real scenario 8 | local readme_path = "/Users/thomask33/GitHub/claudecode.nvim/README.md" 9 | 10 | -- Check if README exists 11 | if vim.fn.filereadable(readme_path) == 0 then 12 | print("❌ README.md not found at", readme_path) 13 | return 14 | end 15 | 16 | -- Read the actual README content 17 | local file = io.open(readme_path, "r") 18 | if not file then 19 | print("❌ Could not read README.md") 20 | return 21 | end 22 | local original_content = file:read("*a") 23 | file:close() 24 | 25 | -- Create the same modification that Claude would make (add license section) 26 | local new_content = original_content 27 | .. "\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n" 28 | 29 | -- Load the openDiff tool 30 | local success, open_diff_tool = pcall(require, "claudecode.tools.open_diff") 31 | if not success then 32 | print("❌ Failed to load openDiff tool:", open_diff_tool) 33 | return 34 | end 35 | 36 | local params = { 37 | old_file_path = readme_path, 38 | new_file_path = readme_path, 39 | new_file_contents = new_content, 40 | tab_name = "✻ [Claude Code] README.md (test) ⧉", 41 | } 42 | 43 | print("📤 Calling openDiff handler...") 44 | print(" Old file:", params.old_file_path) 45 | print(" Tab name:", params.tab_name) 46 | print(" Original content length:", #original_content) 47 | print(" New content length:", #params.new_file_contents) 48 | 49 | -- Call in coroutine context 50 | local co = coroutine.create(function() 51 | local result = open_diff_tool.handler(params) 52 | print("📥 openDiff completed with result:", vim.inspect(result)) 53 | return result 54 | end) 55 | 56 | local start_time = vim.fn.localtime() 57 | local co_success, co_result = coroutine.resume(co) 58 | 59 | if not co_success then 60 | print("❌ openDiff failed:", co_result) 61 | return 62 | end 63 | 64 | local status = coroutine.status(co) 65 | print("🔍 Coroutine status:", status) 66 | 67 | if status == "suspended" then 68 | print("✅ openDiff is properly blocking!") 69 | print("👉 You should see a diff view now") 70 | print("👉 Save or close the diff to continue") 71 | 72 | -- Set up a timer to check when it completes 73 | local timer = vim.loop.new_timer() 74 | if not timer then 75 | print("❌ Failed to create timer") 76 | return 77 | end 78 | timer:start( 79 | 1000, 80 | 1000, 81 | vim.schedule_wrap(function() 82 | local current_status = coroutine.status(co) 83 | if current_status == "dead" then 84 | timer:stop() 85 | timer:close() 86 | local elapsed = vim.fn.localtime() - start_time 87 | print("✅ openDiff completed after " .. elapsed .. " seconds") 88 | elseif current_status ~= "suspended" then 89 | timer:stop() 90 | timer:close() 91 | print("⚠️ Unexpected coroutine status:", current_status) 92 | end 93 | end) 94 | ) 95 | else 96 | print("❌ openDiff did not block (status: " .. status .. ")") 97 | if co_result then 98 | print(" Result:", vim.inspect(co_result)) 99 | end 100 | end 101 | 102 | -- No cleanup needed since we're using the actual README file 103 | end 104 | 105 | -- Run the test 106 | test_opendiff_directly() 107 | -------------------------------------------------------------------------------- /tests/unit/new_file_reject_then_reopen_spec.lua: -------------------------------------------------------------------------------- 1 | -- Verifies that rejecting a new-file diff with an empty buffer left open does not crash, 2 | -- and a subsequent write (diff setup) works again. 3 | require("tests.busted_setup") 4 | 5 | describe("New file diff: reject then reopen", function() 6 | local diff 7 | 8 | before_each(function() 9 | -- Fresh vim mock state 10 | if vim and vim._mock and vim._mock.reset then 11 | vim._mock.reset() 12 | end 13 | 14 | -- Minimal logger stub 15 | package.loaded["claudecode.logger"] = { 16 | debug = function() end, 17 | error = function() end, 18 | info = function() end, 19 | warn = function() end, 20 | } 21 | 22 | -- Reload diff module cleanly 23 | package.loaded["claudecode.diff"] = nil 24 | diff = require("claudecode.diff") 25 | 26 | -- Setup config on diff 27 | diff.setup({ 28 | diff_opts = { 29 | layout = "vertical", 30 | open_in_new_tab = false, 31 | keep_terminal_focus = false, 32 | on_new_file_reject = "keep_empty", -- default behavior 33 | }, 34 | terminal = {}, 35 | }) 36 | 37 | -- Create an empty unnamed buffer and set it in current window so _create_diff_view_from_window reuses it 38 | local empty_buf = vim.api.nvim_create_buf(false, true) 39 | -- Ensure name is empty and 'modified' is false 40 | vim.api.nvim_buf_set_name(empty_buf, "") 41 | vim.api.nvim_buf_set_option(empty_buf, "modified", false) 42 | 43 | -- Make current window use this empty buffer 44 | local current_win = vim.api.nvim_get_current_win() 45 | vim.api.nvim_win_set_buf(current_win, empty_buf) 46 | end) 47 | 48 | it("should reuse empty buffer for new-file diff, not delete it on reject, and allow reopening", function() 49 | local tab_name = "✻ [TestNewFile] new.lua ⧉" 50 | local params = { 51 | old_file_path = "/nonexistent/path/to/new.lua", -- ensure new-file scenario 52 | new_file_path = "/tmp/new.lua", 53 | new_file_contents = "print('hello')\n", 54 | tab_name = tab_name, 55 | } 56 | 57 | -- Track current window buffer (the reused empty buffer) 58 | local target_win = vim.api.nvim_get_current_win() 59 | local reused_buf = vim.api.nvim_win_get_buf(target_win) 60 | assert.is_true(vim.api.nvim_buf_is_valid(reused_buf)) 61 | 62 | -- 1) Setup the diff (should reuse the empty buffer) 63 | local setup_ok, setup_err = pcall(function() 64 | diff._setup_blocking_diff(params, function() end) 65 | end) 66 | assert.is_true(setup_ok, "Diff setup failed unexpectedly: " .. tostring(setup_err)) 67 | 68 | -- Verify state registered (ownership may vary based on window conditions) 69 | local active = diff._get_active_diffs() 70 | assert.is_table(active[tab_name]) 71 | -- Ensure the original buffer reference exists and is valid 72 | assert.is_true(vim.api.nvim_buf_is_valid(active[tab_name].original_buffer)) 73 | 74 | -- 2) Reject the diff; cleanup should NOT delete the reused empty buffer 75 | diff._resolve_diff_as_rejected(tab_name) 76 | 77 | -- After reject, the diff state should be removed 78 | local active_after_reject = diff._get_active_diffs() 79 | assert.is_nil(active_after_reject[tab_name]) 80 | 81 | -- The reused buffer should still be valid (not deleted) 82 | assert.is_true(vim.api.nvim_buf_is_valid(reused_buf)) 83 | 84 | -- 3) Setup the diff again with the same conditions; should succeed 85 | local setup_ok2, setup_err2 = pcall(function() 86 | diff._setup_blocking_diff(params, function() end) 87 | end) 88 | assert.is_true(setup_ok2, "Second diff setup failed unexpectedly: " .. tostring(setup_err2)) 89 | 90 | -- Verify new state exists again 91 | local active_again = diff._get_active_diffs() 92 | assert.is_table(active_again[tab_name]) 93 | 94 | -- Clean up to avoid affecting other tests 95 | diff._cleanup_diff_state(tab_name, "test cleanup") 96 | end) 97 | end) 98 | -------------------------------------------------------------------------------- /lua/claudecode/tools/get_diagnostics.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for getting diagnostics. 2 | 3 | -- NOTE: Its important we don't tip off Claude that we're dealing with Neovim LSP diagnostics because it may adjust 4 | -- line and col numbers by 1 on its own (since it knows nvim LSP diagnostics are 0-indexed). By calling these 5 | -- "editor diagnostics" and converting to 1-indexed ourselves we (hopefully) avoid incorrect line and column numbers 6 | -- in Claude's responses. 7 | local schema = { 8 | description = "Get language diagnostics (errors, warnings) from the editor", 9 | inputSchema = { 10 | type = "object", 11 | properties = { 12 | uri = { 13 | type = "string", 14 | description = "Optional file URI to get diagnostics for. If not provided, gets diagnostics for all open files.", 15 | }, 16 | }, 17 | additionalProperties = false, 18 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 19 | }, 20 | } 21 | 22 | ---Handles the getDiagnostics tool invocation. 23 | ---Retrieves diagnostics from Neovim's diagnostic system. 24 | ---@param params table The input parameters for the tool 25 | ---@return table diagnostics MCP-compliant response with diagnostics data 26 | local function handler(params) 27 | if not vim.lsp or not vim.diagnostic or not vim.diagnostic.get then 28 | -- Returning an empty list or a specific status could be an alternative. 29 | -- For now, let's align with the error pattern for consistency if the feature is unavailable. 30 | error({ 31 | code = -32000, 32 | message = "Feature unavailable", 33 | data = "Diagnostics not available in this editor version/configuration.", 34 | }) 35 | end 36 | 37 | local logger = require("claudecode.logger") 38 | 39 | logger.debug("getDiagnostics handler called with params: " .. vim.inspect(params)) 40 | 41 | -- Extract the uri parameter 42 | local diagnostics 43 | 44 | if not params.uri then 45 | -- Get diagnostics for all buffers 46 | logger.debug("Getting diagnostics for all open buffers") 47 | diagnostics = vim.diagnostic.get(nil) 48 | else 49 | local uri = params.uri 50 | -- Strips the file:// scheme 51 | local filepath = vim.uri_to_fname(uri) 52 | 53 | -- Get buffer number for the specific file 54 | local bufnr = vim.fn.bufnr(filepath) 55 | if bufnr == -1 then 56 | -- File is not open in any buffer, throw an error 57 | logger.debug("File buffer must be open to get diagnostics: " .. filepath) 58 | error({ 59 | code = -32001, 60 | message = "File not open", 61 | data = "File must be open to retrieve diagnostics: " .. filepath, 62 | }) 63 | else 64 | -- Get diagnostics for the specific buffer 65 | logger.debug("Getting diagnostics for bufnr: " .. bufnr) 66 | diagnostics = vim.diagnostic.get(bufnr) 67 | end 68 | end 69 | 70 | local formatted_diagnostics = {} 71 | for _, diagnostic in ipairs(diagnostics) do 72 | local file_path = vim.api.nvim_buf_get_name(diagnostic.bufnr) 73 | -- Ensure we only include diagnostics with valid file paths 74 | if file_path and file_path ~= "" then 75 | table.insert(formatted_diagnostics, { 76 | type = "text", 77 | -- json encode this 78 | text = vim.json.encode({ 79 | -- Use the file path and diagnostic information 80 | filePath = file_path, 81 | -- Convert line and column to 1-indexed 82 | line = diagnostic.lnum + 1, 83 | character = diagnostic.col + 1, 84 | severity = diagnostic.severity, -- e.g., vim.diagnostic.severity.ERROR 85 | message = diagnostic.message, 86 | source = diagnostic.source, 87 | }), 88 | }) 89 | end 90 | end 91 | 92 | return { 93 | content = formatted_diagnostics, 94 | } 95 | end 96 | 97 | return { 98 | name = "getDiagnostics", 99 | schema = schema, 100 | handler = handler, 101 | } 102 | -------------------------------------------------------------------------------- /tests/unit/tools/get_latest_selection_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") -- Ensure test helpers are loaded 2 | 3 | describe("Tool: get_latest_selection", function() 4 | local get_latest_selection_handler 5 | local mock_selection_module 6 | 7 | before_each(function() 8 | -- Mock the selection module 9 | mock_selection_module = { 10 | get_latest_selection = spy.new(function() 11 | -- Default behavior: no selection 12 | return nil 13 | end), 14 | } 15 | package.loaded["claudecode.selection"] = mock_selection_module 16 | 17 | -- Reset and require the module under test 18 | package.loaded["claudecode.tools.get_latest_selection"] = nil 19 | get_latest_selection_handler = require("claudecode.tools.get_latest_selection").handler 20 | 21 | -- Mock vim.json functions 22 | _G.vim = _G.vim or {} 23 | _G.vim.json = _G.vim.json or {} 24 | _G.vim.json.encode = spy.new(function(data, opts) 25 | return require("tests.busted_setup").json_encode(data) 26 | end) 27 | end) 28 | 29 | after_each(function() 30 | package.loaded["claudecode.selection"] = nil 31 | package.loaded["claudecode.tools.get_latest_selection"] = nil 32 | _G.vim.json.encode = nil 33 | end) 34 | 35 | it("should return success=false if no selection is available", function() 36 | mock_selection_module.get_latest_selection = spy.new(function() 37 | return nil 38 | end) 39 | 40 | local success, result = pcall(get_latest_selection_handler, {}) 41 | expect(success).to_be_true() 42 | expect(result).to_be_table() 43 | expect(result.content).to_be_table() 44 | expect(result.content[1]).to_be_table() 45 | expect(result.content[1].type).to_be("text") 46 | 47 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 48 | expect(parsed_result.success).to_be_false() 49 | expect(parsed_result.message).to_be("No selection available") 50 | assert.spy(mock_selection_module.get_latest_selection).was_called() 51 | end) 52 | 53 | it("should return the selection data if available", function() 54 | local mock_sel_data = { 55 | text = "selected text", 56 | filePath = "/path/to/file.lua", 57 | fileUrl = "file:///path/to/file.lua", 58 | selection = { 59 | start = { line = 10, character = 4 }, 60 | ["end"] = { line = 10, character = 17 }, 61 | isEmpty = false, 62 | }, 63 | } 64 | mock_selection_module.get_latest_selection = spy.new(function() 65 | return mock_sel_data 66 | end) 67 | 68 | local success, result = pcall(get_latest_selection_handler, {}) 69 | expect(success).to_be_true() 70 | expect(result).to_be_table() 71 | expect(result.content).to_be_table() 72 | expect(result.content[1]).to_be_table() 73 | expect(result.content[1].type).to_be("text") 74 | 75 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 76 | assert.are.same(mock_sel_data, parsed_result) 77 | assert.spy(mock_selection_module.get_latest_selection).was_called() 78 | end) 79 | 80 | it("should handle pcall failure when requiring selection module", function() 81 | -- Simulate require failing 82 | package.loaded["claudecode.selection"] = nil 83 | local original_require = _G.require 84 | _G.require = function(mod_name) 85 | if mod_name == "claudecode.selection" then 86 | error("Simulated require failure for claudecode.selection") 87 | end 88 | return original_require(mod_name) 89 | end 90 | 91 | local success, err = pcall(get_latest_selection_handler, {}) 92 | _G.require = original_require -- Restore original require 93 | 94 | expect(success).to_be_false() 95 | expect(err).to_be_table() 96 | expect(err.code).to_be(-32000) 97 | expect(err.message).to_be("Internal server error") 98 | expect(err.data).to_be("Failed to load selection module") 99 | end) 100 | end) 101 | -------------------------------------------------------------------------------- /tests/unit/focus_after_send_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") 2 | require("tests.mocks.vim") 3 | 4 | describe("focus_after_send behavior", function() 5 | local saved_require 6 | local claudecode 7 | 8 | local mock_terminal 9 | local mock_logger 10 | local mock_server_facade 11 | 12 | local function setup_mocks(focus_after_send) 13 | mock_terminal = { 14 | setup = function() end, 15 | open = spy.new(function() end), 16 | ensure_visible = spy.new(function() end), 17 | } 18 | 19 | mock_logger = { 20 | setup = function() end, 21 | debug = function() end, 22 | info = function() end, 23 | warn = function() end, 24 | error = function() end, 25 | } 26 | 27 | mock_server_facade = { 28 | broadcast = spy.new(function() 29 | return true 30 | end), 31 | } 32 | 33 | local mock_config = { 34 | apply = function() 35 | -- Return only fields used in this test path 36 | return { 37 | auto_start = false, 38 | terminal_cmd = nil, 39 | env = {}, 40 | log_level = "info", 41 | track_selection = false, 42 | focus_after_send = focus_after_send, 43 | diff_opts = { 44 | layout = "vertical", 45 | open_in_new_tab = false, 46 | keep_terminal_focus = false, 47 | on_new_file_reject = "keep_empty", 48 | }, 49 | models = { { name = "Claude Sonnet 4 (Latest)", value = "sonnet" } }, 50 | } 51 | end, 52 | } 53 | 54 | saved_require = _G.require 55 | _G.require = function(mod) 56 | if mod == "claudecode.config" then 57 | return mock_config 58 | elseif mod == "claudecode.logger" then 59 | return mock_logger 60 | elseif mod == "claudecode.diff" then 61 | return { setup = function() end } 62 | elseif mod == "claudecode.terminal" then 63 | return mock_terminal 64 | elseif mod == "claudecode.server.init" then 65 | return { 66 | get_status = function() 67 | return { running = true, client_count = 1 } 68 | end, 69 | } 70 | else 71 | return saved_require(mod) 72 | end 73 | end 74 | end 75 | 76 | local function teardown_mocks() 77 | _G.require = saved_require 78 | package.loaded["claudecode"] = nil 79 | package.loaded["claudecode.config"] = nil 80 | package.loaded["claudecode.logger"] = nil 81 | package.loaded["claudecode.diff"] = nil 82 | package.loaded["claudecode.terminal"] = nil 83 | package.loaded["claudecode.server.init"] = nil 84 | end 85 | 86 | after_each(function() 87 | teardown_mocks() 88 | end) 89 | 90 | it("focuses terminal with open() when enabled", function() 91 | setup_mocks(true) 92 | 93 | claudecode = require("claudecode") 94 | claudecode.setup({}) 95 | 96 | -- Mark server as present and stub low-level broadcast to succeed 97 | claudecode.state.server = mock_server_facade 98 | claudecode._broadcast_at_mention = spy.new(function() 99 | return true, nil 100 | end) 101 | 102 | -- Act 103 | local ok, err = claudecode.send_at_mention("/tmp/file.lua", nil, nil, "test") 104 | assert.is_true(ok) 105 | assert.is_nil(err) 106 | 107 | -- Assert focus behavior 108 | assert.spy(mock_terminal.open).was_called() 109 | assert.spy(mock_terminal.ensure_visible).was_not_called() 110 | end) 111 | 112 | it("only ensures visibility when disabled (default)", function() 113 | setup_mocks(false) 114 | 115 | claudecode = require("claudecode") 116 | claudecode.setup({}) 117 | 118 | claudecode.state.server = mock_server_facade 119 | claudecode._broadcast_at_mention = spy.new(function() 120 | return true, nil 121 | end) 122 | 123 | local ok, err = claudecode.send_at_mention("/tmp/file.lua", nil, nil, "test") 124 | assert.is_true(ok) 125 | assert.is_nil(err) 126 | 127 | assert.spy(mock_terminal.ensure_visible).was_called() 128 | assert.spy(mock_terminal.open).was_not_called() 129 | end) 130 | end) 131 | -------------------------------------------------------------------------------- /tests/unit/tools/close_all_diff_tabs_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") -- Ensure test helpers are loaded 2 | 3 | describe("Tool: close_all_diff_tabs", function() 4 | local close_all_diff_tabs_handler 5 | 6 | before_each(function() 7 | package.loaded["claudecode.tools.close_all_diff_tabs"] = nil 8 | close_all_diff_tabs_handler = require("claudecode.tools.close_all_diff_tabs").handler 9 | 10 | _G.vim = _G.vim or {} 11 | _G.vim.api = _G.vim.api or {} 12 | _G.vim.fn = _G.vim.fn or {} 13 | 14 | -- Default mocks 15 | _G.vim.api.nvim_list_wins = spy.new(function() 16 | return {} 17 | end) 18 | _G.vim.api.nvim_win_get_buf = spy.new(function() 19 | return 1 20 | end) 21 | _G.vim.api.nvim_buf_get_option = spy.new(function() 22 | return "" 23 | end) 24 | _G.vim.api.nvim_win_get_option = spy.new(function() 25 | return false 26 | end) 27 | _G.vim.api.nvim_buf_get_name = spy.new(function() 28 | return "" 29 | end) 30 | _G.vim.api.nvim_list_bufs = spy.new(function() 31 | return {} 32 | end) 33 | _G.vim.api.nvim_buf_is_loaded = spy.new(function() 34 | return false 35 | end) 36 | _G.vim.api.nvim_win_is_valid = spy.new(function() 37 | return true 38 | end) 39 | _G.vim.api.nvim_win_close = spy.new(function() 40 | return true 41 | end) 42 | _G.vim.api.nvim_buf_delete = spy.new(function() 43 | return true 44 | end) 45 | _G.vim.fn.win_findbuf = spy.new(function() 46 | return {} 47 | end) 48 | end) 49 | 50 | after_each(function() 51 | package.loaded["claudecode.tools.close_all_diff_tabs"] = nil 52 | -- Clear all mocks 53 | _G.vim.api.nvim_list_wins = nil 54 | _G.vim.api.nvim_win_get_buf = nil 55 | _G.vim.api.nvim_buf_get_option = nil 56 | _G.vim.api.nvim_win_get_option = nil 57 | _G.vim.api.nvim_buf_get_name = nil 58 | _G.vim.api.nvim_list_bufs = nil 59 | _G.vim.api.nvim_buf_is_loaded = nil 60 | _G.vim.api.nvim_win_is_valid = nil 61 | _G.vim.api.nvim_win_close = nil 62 | _G.vim.api.nvim_buf_delete = nil 63 | _G.vim.fn.win_findbuf = nil 64 | end) 65 | 66 | it("should return CLOSED_0_DIFF_TABS when no diff tabs found", function() 67 | local success, result = pcall(close_all_diff_tabs_handler, {}) 68 | expect(success).to_be_true() 69 | expect(result).to_be_table() 70 | expect(result.content).to_be_table() 71 | expect(result.content[1]).to_be_table() 72 | expect(result.content[1].type).to_be("text") 73 | expect(result.content[1].text).to_be("CLOSED_0_DIFF_TABS") 74 | end) 75 | 76 | it("should close windows in diff mode", function() 77 | _G.vim.api.nvim_list_wins = spy.new(function() 78 | return { 1, 2 } 79 | end) 80 | _G.vim.api.nvim_win_get_option = spy.new(function(win, opt) 81 | if opt == "diff" then 82 | return win == 1 -- Only window 1 is in diff mode 83 | end 84 | return false 85 | end) 86 | 87 | local success, result = pcall(close_all_diff_tabs_handler, {}) 88 | expect(success).to_be_true() 89 | expect(result.content[1].text).to_be("CLOSED_1_DIFF_TABS") 90 | assert.spy(_G.vim.api.nvim_win_close).was_called_with(1, false) 91 | end) 92 | 93 | it("should close diff-related buffers", function() 94 | _G.vim.api.nvim_list_bufs = spy.new(function() 95 | return { 1, 2 } 96 | end) 97 | _G.vim.api.nvim_buf_is_loaded = spy.new(function() 98 | return true 99 | end) 100 | _G.vim.api.nvim_buf_get_name = spy.new(function(buf) 101 | if buf == 1 then 102 | return "/path/to/file.diff" 103 | end 104 | if buf == 2 then 105 | return "/path/to/normal.txt" 106 | end 107 | return "" 108 | end) 109 | _G.vim.fn.win_findbuf = spy.new(function() 110 | return {} -- No windows for these buffers 111 | end) 112 | 113 | local success, result = pcall(close_all_diff_tabs_handler, {}) 114 | expect(success).to_be_true() 115 | expect(result.content[1].text).to_be("CLOSED_1_DIFF_TABS") 116 | assert.spy(_G.vim.api.nvim_buf_delete).was_called_with(1, { force = true }) 117 | end) 118 | end) 119 | -------------------------------------------------------------------------------- /STORY.md: -------------------------------------------------------------------------------- 1 | # The Story: How I Reverse-Engineered Claude's IDE Protocol 2 | 3 | ## The Reddit Post That Started Everything 4 | 5 | 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. 6 | 7 | Link to the Reddit post: 8 | 9 | My first thought? "No way, they wouldn't ship the source like that." 10 | 11 | But curiosity got the better of me. I checked npm, and there they were — the .vsix files, just sitting in the vendor folder. 12 | 13 | ## Down the Rabbit Hole 14 | 15 | 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. 16 | 17 | 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. 18 | 19 | ## Using AI to Understand AI 20 | 21 | I had a crazy idea: What if I used Claude to help me understand Claude's own extension? 22 | 23 | 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: 24 | 25 | ```javascript 26 | // Before 27 | const L = new McpToolRegistry(); 28 | 29 | // After 30 | const toolRegistry = new McpToolRegistry(); 31 | ``` 32 | 33 | Suddenly, patterns emerged. Functions revealed their purpose. The fog lifted. 34 | 35 | ## The Discovery 36 | 37 | What I discovered was fascinating: 38 | 39 | 1. **It's all MCP** — The extensions use Model Context Protocol, but with a twist 40 | 2. **WebSocket Transport** — Unlike standard MCP (which uses stdio/HTTP), these use WebSockets 41 | 3. **Claude-Specific** — Claude Code is the only MCP client that supports WebSocket transport 42 | 4. **Simple Protocol** — The IDE creates a server, Claude connects to it 43 | 44 | ## Building for Neovim 45 | 46 | Armed with this knowledge, I faced a new challenge: I wanted this in Neovim, but I didn't know Lua. 47 | 48 | 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.) 49 | 50 | 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. 51 | 52 | ## The Technical Challenge 53 | 54 | Building a WebSocket server in pure Lua with only Neovim built-ins was... interesting: 55 | 56 | - Implemented SHA-1 from scratch (needed for WebSocket handshake) 57 | - Built a complete RFC 6455 WebSocket frame parser 58 | - Created base64 encoding/decoding functions 59 | - All using just `vim.loop` and basic Lua 60 | 61 | No external dependencies. Just pure, unadulterated Neovim. 62 | 63 | ## What This Means 64 | 65 | This discovery opens doors: 66 | 67 | 1. **Any editor can integrate** — The protocol is simple and well-defined 68 | 2. **Agents can connect** — You could build automation that connects to any IDE with these extensions 69 | 3. **The protocol is extensible** — New tools can be added easily 70 | 4. **It's educational** — Understanding how these work demystifies AI coding assistants 71 | 72 | ## Lessons Learned 73 | 74 | 1. **Curiosity pays off** — That Reddit post led to this entire journey 75 | 2. **Tools matter** — AST-grep was instrumental in understanding the code 76 | 3. **AI can build AI tools** — We're in a recursive loop of AI development 77 | 4. **Open source wins** — By understanding the protocol, we can build for any platform 78 | 79 | ## What's Next? 80 | 81 | The protocol is documented. The implementation is open source. Now it's your turn. 82 | 83 | Build integrations for Emacs, Sublime, or your favorite editor. Create agents that leverage IDE access. Extend the protocol with new capabilities. 84 | 85 | The genie is out of the bottle, and it speaks WebSocket. 86 | 87 | --- 88 | 89 | _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._ 90 | -------------------------------------------------------------------------------- /dev-config.lua: -------------------------------------------------------------------------------- 1 | -- Development configuration for claudecode.nvim 2 | -- This is Thomas's personal config for developing claudecode.nvim 3 | -- Symlink this to your personal Neovim config: 4 | -- ln -s ~/projects/claudecode.nvim/dev-config.lua ~/.config/nvim/lua/plugins/dev-claudecode.lua 5 | 6 | return { 7 | "coder/claudecode.nvim", 8 | dev = true, -- Use local development version 9 | keys = { 10 | -- AI/Claude Code prefix 11 | { "a", nil, desc = "AI/Claude Code" }, 12 | 13 | -- Core Claude commands 14 | { "ac", "ClaudeCode", desc = "Toggle Claude" }, 15 | { "af", "ClaudeCodeFocus", desc = "Focus Claude" }, 16 | { "ar", "ClaudeCode --resume", desc = "Resume Claude" }, 17 | { "aC", "ClaudeCode --continue", desc = "Continue Claude" }, 18 | { "am", "ClaudeCodeSelectModel", desc = "Select Claude model" }, 19 | 20 | -- Context sending 21 | { "as", "ClaudeCodeAdd %", mode = "n", desc = "Add current buffer" }, 22 | { "as", "ClaudeCodeSend", mode = "v", desc = "Send to Claude" }, 23 | { 24 | "as", 25 | "ClaudeCodeTreeAdd", 26 | desc = "Add file from tree", 27 | ft = { "NvimTree", "neo-tree", "oil", "minifiles", "netrw" }, 28 | }, 29 | 30 | -- Development helpers 31 | { "ao", "ClaudeCodeOpen", desc = "Open Claude" }, 32 | { "aq", "ClaudeCodeClose", desc = "Close Claude" }, 33 | { "ai", "ClaudeCodeStatus", desc = "Claude Status" }, 34 | { "aS", "ClaudeCodeStart", desc = "Start Claude Server" }, 35 | { "aQ", "ClaudeCodeStop", desc = "Stop Claude Server" }, 36 | 37 | -- Diff management (buffer-local, only active in diff buffers) 38 | { "aa", "ClaudeCodeDiffAccept", desc = "Accept diff" }, 39 | { "ad", "ClaudeCodeDiffDeny", desc = "Deny diff" }, 40 | }, 41 | 42 | -- Development configuration - all options shown with defaults commented out 43 | ---@type PartialClaudeCodeConfig 44 | opts = { 45 | -- Server Configuration 46 | -- port_range = { min = 10000, max = 65535 }, -- WebSocket server port range 47 | -- auto_start = true, -- Auto-start server on Neovim startup 48 | -- log_level = "info", -- "trace", "debug", "info", "warn", "error" 49 | -- terminal_cmd = nil, -- Custom terminal command (default: "claude") 50 | 51 | -- Send/Focus Behavior 52 | focus_after_send = true, -- Focus Claude terminal after successful send while connected 53 | 54 | -- Selection Tracking 55 | -- track_selection = true, -- Enable real-time selection tracking 56 | -- visual_demotion_delay_ms = 50, -- Delay before demoting visual selection (ms) 57 | 58 | -- Connection Management 59 | -- connection_wait_delay = 200, -- Wait time after connection before sending queued @ mentions (ms) 60 | -- connection_timeout = 10000, -- Max time to wait for Claude Code connection (ms) 61 | -- queue_timeout = 5000, -- Max time to keep @ mentions in queue (ms) 62 | 63 | -- Diff Integration 64 | -- diff_opts = { 65 | -- layout = "horizontal", -- "vertical" or "horizontal" diff layout 66 | -- open_in_new_tab = true, -- Open diff in a new tab (false = use current tab) 67 | -- keep_terminal_focus = true, -- Keep focus in terminal after opening diff 68 | -- hide_terminal_in_new_tab = true, -- Hide Claude terminal in the new diff tab for more review space 69 | -- }, 70 | 71 | -- Terminal Configuration 72 | -- terminal = { 73 | -- split_side = "right", -- "left" or "right" 74 | -- split_width_percentage = 0.30, -- Width as percentage (0.0 to 1.0) 75 | -- provider = "auto", -- "auto", "snacks", or "native" 76 | -- show_native_term_exit_tip = true, -- Show exit tip for native terminal 77 | -- auto_close = true, -- Auto-close terminal after command completion 78 | -- snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` 79 | -- }, 80 | 81 | -- Development overrides (uncomment as needed) 82 | -- log_level = "debug", 83 | -- terminal = { 84 | -- provider = "native", 85 | -- auto_close = false, -- Keep terminals open to see output 86 | -- }, 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /tests/unit/diff_ui_cleanup_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") 2 | 3 | local diff = require("claudecode.diff") 4 | 5 | describe("Diff UI cleanup behavior", function() 6 | local test_old_file = "/tmp/test_ui_cleanup_old.txt" 7 | local tab_name = "test_ui_cleanup_tab" 8 | 9 | before_each(function() 10 | -- Prepare a dummy file 11 | local f = io.open(test_old_file, "w") 12 | f:write("line1\nline2\n") 13 | f:close() 14 | 15 | -- Reset tabs mock 16 | vim._tabs = { [1] = true, [2] = true } 17 | vim._current_tabpage = 2 -- Simulate we're on the newly created tab during cleanup 18 | end) 19 | 20 | after_each(function() 21 | os.remove(test_old_file) 22 | -- Ensure cleanup doesn't leave state behind 23 | diff._cleanup_all_active_diffs("test_teardown") 24 | end) 25 | 26 | it("closes the created new tab on accept only after close_tab is invoked", function() 27 | -- Minimal windows/buffers for cleanup paths 28 | local new_win = 2001 29 | local target_win = 2002 30 | vim._windows[new_win] = { buf = 2 } 31 | vim._windows[target_win] = { buf = 3 } 32 | 33 | -- Register a pending diff that was opened in a new tab 34 | diff._register_diff_state(tab_name, { 35 | old_file_path = test_old_file, 36 | new_window = new_win, 37 | target_window = target_win, 38 | new_buffer = 2, 39 | original_buffer = 3, 40 | original_cursor_pos = { 1, 0 }, 41 | original_tab_number = 1, 42 | created_new_tab = true, 43 | new_tab_number = 2, 44 | had_terminal_in_original = false, 45 | autocmd_ids = {}, 46 | status = "pending", 47 | resolution_callback = function(_) end, 48 | is_new_file = false, 49 | }) 50 | 51 | -- Resolve as saved: should NOT close the tab yet 52 | diff._resolve_diff_as_saved(tab_name, 2) 53 | assert.is_true( 54 | vim._last_command == nil or vim._last_command:match("^tabclose") == nil, 55 | "Did not expect ':tabclose' before close_tab tool call" 56 | ) 57 | 58 | -- Simulate close_tab tool invocation 59 | local closed = diff.close_diff_by_tab_name(tab_name) 60 | assert.is_true(closed) 61 | -- Verify a tabclose command was issued now 62 | assert.is_true( 63 | type(vim._last_command) == "string" and vim._last_command:match("^tabclose") ~= nil, 64 | "Expected a ':tabclose' command to be executed on close_tab" 65 | ) 66 | end) 67 | 68 | it("keeps Claude terminal visible in original tab on reject when previously visible", function() 69 | -- Spy on terminal.ensure_visible by preloading a stub module 70 | local ensure_calls = 0 71 | package.loaded["claudecode.terminal"] = { 72 | ensure_visible = function() 73 | ensure_calls = ensure_calls + 1 74 | return true 75 | end, 76 | get_active_terminal_bufnr = function() 77 | return nil 78 | end, 79 | } 80 | 81 | -- Minimal windows/buffers for cleanup paths 82 | local new_win = 2101 83 | local target_win = 2102 84 | vim._windows[new_win] = { buf = 4 } 85 | vim._windows[target_win] = { buf = 5 } 86 | 87 | -- Register a pending diff that was opened in a new tab, and track that 88 | -- the terminal was visible in the original tab when the diff was created 89 | diff._register_diff_state(tab_name, { 90 | old_file_path = test_old_file, 91 | new_window = new_win, 92 | target_window = target_win, 93 | new_buffer = 4, 94 | original_buffer = 5, 95 | original_cursor_pos = { 1, 0 }, 96 | original_tab_number = 1, 97 | created_new_tab = true, 98 | new_tab_number = 2, 99 | had_terminal_in_original = true, 100 | autocmd_ids = {}, 101 | status = "pending", 102 | resolution_callback = function(_) end, 103 | is_new_file = false, 104 | }) 105 | 106 | -- Mark as rejected and verify no cleanup yet 107 | diff._resolve_diff_as_rejected(tab_name) 108 | assert.equals(0, ensure_calls) 109 | 110 | -- Simulate close_tab tool invocation for a pending diff (treated as reject) 111 | local closed = diff.close_diff_by_tab_name(tab_name) 112 | assert.is_true(closed) 113 | -- ensure_visible should have been called exactly once during cleanup 114 | assert.equals(1, ensure_calls) 115 | 116 | -- Clear the stub to avoid side effects for other tests 117 | package.loaded["claudecode.terminal"] = nil 118 | end) 119 | end) 120 | -------------------------------------------------------------------------------- /tests/unit/diff_hide_terminal_new_tab_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") 2 | 3 | describe("Diff new-tab with hidden terminal", function() 4 | local open_diff_tool = require("claudecode.tools.open_diff") 5 | local diff = require("claudecode.diff") 6 | 7 | local test_old_file = "/tmp/claudecode_diff_hide_old.txt" 8 | local test_new_file = "/tmp/claudecode_diff_hide_new.txt" 9 | local test_tab_name = "hide-term-in-new-tab" 10 | 11 | before_each(function() 12 | -- Create a real file so filereadable() returns 1 in mocks 13 | local f = io.open(test_old_file, "w") 14 | f:write("line1\nline2\n") 15 | f:close() 16 | 17 | -- Ensure a clean diff state 18 | diff._cleanup_all_active_diffs("test_setup") 19 | 20 | -- Provide minimal config directly to diff module 21 | diff.setup({ 22 | terminal = { split_side = "right", split_width_percentage = 0.30 }, 23 | diff_opts = { 24 | layout = "vertical", 25 | open_in_new_tab = true, 26 | keep_terminal_focus = false, 27 | hide_terminal_in_new_tab = true, 28 | }, 29 | }) 30 | 31 | -- Stub terminal provider with a valid terminal buffer (should be ignored due to hide flag) 32 | local term_buf = vim.api.nvim_create_buf(false, true) 33 | package.loaded["claudecode.terminal"] = { 34 | get_active_terminal_bufnr = function() 35 | return term_buf 36 | end, 37 | ensure_visible = function() end, 38 | } 39 | end) 40 | 41 | after_each(function() 42 | os.remove(test_old_file) 43 | os.remove(test_new_file) 44 | -- Clear stub to avoid side effects 45 | package.loaded["claudecode.terminal"] = nil 46 | diff._cleanup_all_active_diffs("test_teardown") 47 | end) 48 | 49 | it("does not place a terminal split in the new tab when hidden", function() 50 | local params = { 51 | old_file_path = test_old_file, 52 | new_file_path = test_new_file, 53 | new_file_contents = "updated content\n", 54 | tab_name = test_tab_name, 55 | } 56 | 57 | local co = coroutine.create(function() 58 | open_diff_tool.handler(params) 59 | end) 60 | 61 | -- Start the tool (it will yield waiting for user action) 62 | local ok, err = coroutine.resume(co) 63 | assert.is_true(ok, tostring(err)) 64 | assert.equal("suspended", coroutine.status(co)) 65 | 66 | -- Inspect active diff metadata 67 | local active = diff._get_active_diffs() 68 | assert.is_table(active[test_tab_name]) 69 | assert.is_true(active[test_tab_name].created_new_tab) 70 | -- Key assertion: no terminal window was created in the new tab 71 | assert.is_nil(active[test_tab_name].terminal_win_in_new_tab) 72 | 73 | -- Resolve to finish the coroutine 74 | vim.schedule(function() 75 | diff._resolve_diff_as_rejected(test_tab_name) 76 | end) 77 | vim.wait(100, function() 78 | return coroutine.status(co) == "dead" 79 | end) 80 | end) 81 | 82 | it("wipes the initial unnamed buffer created by tabnew", function() 83 | local params = { 84 | old_file_path = test_old_file, 85 | new_file_path = test_new_file, 86 | new_file_contents = "updated content\n", 87 | tab_name = test_tab_name, 88 | } 89 | 90 | -- Start handler 91 | local co = coroutine.create(function() 92 | open_diff_tool.handler(params) 93 | end) 94 | 95 | local ok, err = coroutine.resume(co) 96 | assert.is_true(ok, tostring(err)) 97 | assert.equal("suspended", coroutine.status(co)) 98 | 99 | -- After diff opens, the initial unnamed buffer in the new tab should be gone 100 | -- because plugin marks it bufhidden=wipe and then replaces it 101 | local unnamed_count = 0 102 | for _, buf in pairs(vim._buffers) do 103 | if buf.name == nil or buf.name == "" then 104 | unnamed_count = unnamed_count + 1 105 | end 106 | end 107 | -- There may be zero unnamed buffers, or other tests may create scratch buffers with names 108 | -- The important assertion is that there is no unnamed buffer with bufhidden=wipe lingering 109 | for id, buf in pairs(vim._buffers) do 110 | local bh = buf.options and buf.options.bufhidden or nil 111 | assert.not_equal("wipe", bh, "Found lingering unnamed buffer with bufhidden=wipe (buf " .. tostring(id) .. ")") 112 | end 113 | 114 | -- Cleanup by rejecting 115 | vim.schedule(function() 116 | diff._resolve_diff_as_rejected(test_tab_name) 117 | end) 118 | vim.wait(100, function() 119 | return coroutine.status(co) == "dead" 120 | end) 121 | end) 122 | end) 123 | -------------------------------------------------------------------------------- /lua/claudecode/tools/get_open_editors.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for getting a list of open editors. 2 | 3 | local schema = { 4 | description = "Get list of currently open files", 5 | inputSchema = { 6 | type = "object", 7 | additionalProperties = false, 8 | ["$schema"] = "http://json-schema.org/draft-07/schema#", 9 | }, 10 | } 11 | 12 | ---Handles the getOpenEditors tool invocation. 13 | ---Gets a list of currently open and listed files in Neovim. 14 | ---@return table response MCP-compliant response with editor tabs data 15 | local function handler(params) 16 | local tabs = {} 17 | local buffers = vim.api.nvim_list_bufs() 18 | local current_buf = vim.api.nvim_get_current_buf() 19 | local current_tabpage = vim.api.nvim_get_current_tabpage() 20 | 21 | -- Get selection for active editor if available 22 | local active_selection = nil 23 | local selection_module_ok, selection_module = pcall(require, "claudecode.selection") 24 | if selection_module_ok then 25 | active_selection = selection_module.get_latest_selection() 26 | end 27 | 28 | for _, bufnr in ipairs(buffers) do 29 | -- Only include loaded, listed buffers with a file path 30 | if vim.api.nvim_buf_is_loaded(bufnr) and vim.fn.buflisted(bufnr) == 1 then 31 | local file_path = vim.api.nvim_buf_get_name(bufnr) 32 | 33 | if file_path and file_path ~= "" then 34 | -- Get the filename for the label 35 | local ok_label, label = pcall(vim.fn.fnamemodify, file_path, ":t") 36 | if not ok_label then 37 | label = file_path -- Fallback to full path 38 | end 39 | 40 | -- Get language ID (filetype) 41 | local ok_lang, language_id = pcall(vim.api.nvim_buf_get_option, bufnr, "filetype") 42 | if not ok_lang or language_id == nil or language_id == "" then 43 | language_id = "plaintext" 44 | end 45 | 46 | -- Get line count 47 | local line_count = 0 48 | local ok_lines, lines_result = pcall(vim.api.nvim_buf_line_count, bufnr) 49 | if ok_lines then 50 | line_count = lines_result 51 | end 52 | 53 | -- Check if untitled (no file path or special buffer) 54 | local is_untitled = ( 55 | not file_path 56 | or file_path == "" 57 | or string.match(file_path, "^%s*$") ~= nil 58 | or string.match(file_path, "^term://") ~= nil 59 | or string.match(file_path, "^%[.*%]$") ~= nil 60 | ) 61 | 62 | -- Get tabpage info for this buffer 63 | -- For simplicity, use current tabpage as the "group" for all buffers 64 | -- In a more complex implementation, we could track which tabpage last showed each buffer 65 | local group_index = current_tabpage - 1 -- 0-based 66 | local view_column = current_tabpage -- 1-based 67 | local is_group_active = true -- Current tabpage is always active 68 | 69 | -- Build tab object with all VS Code fields 70 | local tab = { 71 | uri = "file://" .. file_path, 72 | isActive = bufnr == current_buf, 73 | isPinned = false, -- Neovim doesn't have pinned tabs 74 | isPreview = false, -- Neovim doesn't have preview tabs 75 | isDirty = (function() 76 | local ok, modified = pcall(vim.api.nvim_buf_get_option, bufnr, "modified") 77 | return ok and modified or false 78 | end)(), 79 | label = label, 80 | groupIndex = group_index, 81 | viewColumn = view_column, 82 | isGroupActive = is_group_active, 83 | fileName = file_path, 84 | languageId = language_id, 85 | lineCount = line_count, 86 | isUntitled = is_untitled, 87 | } 88 | 89 | -- Add selection info for active editor 90 | if bufnr == current_buf and active_selection and active_selection.selection then 91 | tab.selection = { 92 | start = active_selection.selection.start, 93 | ["end"] = active_selection.selection["end"], 94 | isReversed = false, -- Neovim doesn't track reversed selections like VS Code 95 | } 96 | end 97 | 98 | table.insert(tabs, tab) 99 | end 100 | end 101 | end 102 | 103 | -- Return MCP-compliant format with JSON-stringified tabs array matching VS Code format 104 | return { 105 | content = { 106 | { 107 | type = "text", 108 | text = vim.json.encode({ tabs = tabs }, { indent = 2 }), 109 | }, 110 | }, 111 | } 112 | end 113 | 114 | return { 115 | name = "getOpenEditors", 116 | schema = schema, 117 | handler = handler, 118 | } 119 | -------------------------------------------------------------------------------- /tests/unit/tools/check_document_dirty_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") -- Ensure test helpers are loaded 2 | 3 | describe("Tool: check_document_dirty", function() 4 | local check_document_dirty_handler 5 | 6 | before_each(function() 7 | package.loaded["claudecode.tools.check_document_dirty"] = nil 8 | check_document_dirty_handler = require("claudecode.tools.check_document_dirty").handler 9 | 10 | _G.vim = _G.vim or {} 11 | _G.vim.fn = _G.vim.fn or {} 12 | _G.vim.api = _G.vim.api or {} 13 | 14 | -- Mock vim.json.encode 15 | _G.vim.json = _G.vim.json or {} 16 | _G.vim.json.encode = spy.new(function(data, opts) 17 | return require("tests.busted_setup").json_encode(data) 18 | end) 19 | 20 | -- Default mocks 21 | _G.vim.fn.bufnr = spy.new(function(filePath) 22 | if filePath == "/path/to/open_file.lua" then 23 | return 1 24 | end 25 | if filePath == "/path/to/another_open_file.txt" then 26 | return 2 27 | end 28 | return -1 -- File not open 29 | end) 30 | _G.vim.api.nvim_buf_get_option = spy.new(function(bufnr, option_name) 31 | if option_name == "modified" then 32 | if bufnr == 1 then 33 | return false 34 | end -- open_file.lua is clean 35 | if bufnr == 2 then 36 | return true 37 | end -- another_open_file.txt is dirty 38 | end 39 | return nil -- Default for other options or unknown bufnr 40 | end) 41 | _G.vim.api.nvim_buf_get_name = spy.new(function(bufnr) 42 | if bufnr == 1 then 43 | return "/path/to/open_file.lua" 44 | end 45 | if bufnr == 2 then 46 | return "/path/to/another_open_file.txt" 47 | end 48 | return "" 49 | end) 50 | end) 51 | 52 | after_each(function() 53 | package.loaded["claudecode.tools.check_document_dirty"] = nil 54 | _G.vim.fn.bufnr = nil 55 | _G.vim.api.nvim_buf_get_option = nil 56 | _G.vim.api.nvim_buf_get_name = nil 57 | _G.vim.json.encode = nil 58 | end) 59 | 60 | it("should error if filePath parameter is missing", function() 61 | local success, err = pcall(check_document_dirty_handler, {}) 62 | expect(success).to_be_false() 63 | expect(err).to_be_table() 64 | expect(err.code).to_be(-32602) 65 | assert_contains(err.data, "Missing filePath parameter") 66 | end) 67 | 68 | it("should return success=false if file is not open in editor", function() 69 | local params = { filePath = "/path/to/non_open_file.py" } 70 | local success, result = pcall(check_document_dirty_handler, params) 71 | expect(success).to_be_true() -- No longer throws error, returns success=false 72 | expect(result).to_be_table() 73 | expect(result.content).to_be_table() 74 | expect(result.content[1]).to_be_table() 75 | expect(result.content[1].type).to_be("text") 76 | 77 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 78 | expect(parsed_result.success).to_be_false() 79 | expect(parsed_result.message).to_be("Document not open: /path/to/non_open_file.py") 80 | 81 | assert.spy(_G.vim.fn.bufnr).was_called_with("/path/to/non_open_file.py") 82 | end) 83 | 84 | it("should return isDirty=false for a clean open file", function() 85 | local params = { filePath = "/path/to/open_file.lua" } 86 | local success, result = pcall(check_document_dirty_handler, params) 87 | expect(success).to_be_true() 88 | expect(result).to_be_table() 89 | expect(result.content).to_be_table() 90 | expect(result.content[1]).to_be_table() 91 | expect(result.content[1].type).to_be("text") 92 | 93 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 94 | expect(parsed_result.success).to_be_true() 95 | expect(parsed_result.isDirty).to_be_false() 96 | expect(parsed_result.isUntitled).to_be_false() 97 | expect(parsed_result.filePath).to_be("/path/to/open_file.lua") 98 | 99 | assert.spy(_G.vim.fn.bufnr).was_called_with("/path/to/open_file.lua") 100 | assert.spy(_G.vim.api.nvim_buf_get_option).was_called_with(1, "modified") 101 | end) 102 | 103 | it("should return isDirty=true for a dirty open file", function() 104 | local params = { filePath = "/path/to/another_open_file.txt" } 105 | local success, result = pcall(check_document_dirty_handler, params) 106 | expect(success).to_be_true() 107 | expect(result).to_be_table() 108 | expect(result.content).to_be_table() 109 | expect(result.content[1]).to_be_table() 110 | expect(result.content[1].type).to_be("text") 111 | 112 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 113 | expect(parsed_result.success).to_be_true() 114 | expect(parsed_result.isDirty).to_be_true() 115 | expect(parsed_result.isUntitled).to_be_false() 116 | expect(parsed_result.filePath).to_be("/path/to/another_open_file.txt") 117 | 118 | assert.spy(_G.vim.fn.bufnr).was_called_with("/path/to/another_open_file.txt") 119 | assert.spy(_G.vim.api.nvim_buf_get_option).was_called_with(2, "modified") 120 | end) 121 | end) 122 | -------------------------------------------------------------------------------- /lua/claudecode/logger.lua: -------------------------------------------------------------------------------- 1 | ---@brief Centralized logger for Claude Code Neovim integration. 2 | -- Provides level-based logging. 3 | ---@module 'claudecode.logger' 4 | local M = {} 5 | 6 | M.levels = { 7 | ERROR = 1, 8 | WARN = 2, 9 | INFO = 3, 10 | DEBUG = 4, 11 | TRACE = 5, 12 | } 13 | 14 | local level_values = { 15 | error = M.levels.ERROR, 16 | warn = M.levels.WARN, 17 | info = M.levels.INFO, 18 | debug = M.levels.DEBUG, 19 | trace = M.levels.TRACE, 20 | } 21 | 22 | local current_log_level_value = M.levels.INFO 23 | 24 | ---Setup the logger module 25 | ---@param plugin_config ClaudeCodeConfig The configuration table (e.g., from claudecode.init.state.config). 26 | function M.setup(plugin_config) 27 | local conf = plugin_config 28 | 29 | if conf and conf.log_level and level_values[conf.log_level] then 30 | current_log_level_value = level_values[conf.log_level] 31 | else 32 | vim.notify( 33 | "ClaudeCode Logger: Invalid or missing log_level in configuration (received: " 34 | .. tostring(conf and conf.log_level) 35 | .. "). Defaulting to INFO.", 36 | vim.log.levels.WARN 37 | ) 38 | current_log_level_value = M.levels.INFO 39 | end 40 | end 41 | 42 | local function log(level, component, message_parts) 43 | if level > current_log_level_value then 44 | return 45 | end 46 | 47 | local prefix = "[ClaudeCode]" 48 | if component then 49 | prefix = prefix .. " [" .. component .. "]" 50 | end 51 | 52 | local level_name = "UNKNOWN" 53 | for name, val in pairs(M.levels) do 54 | if val == level then 55 | level_name = name 56 | break 57 | end 58 | end 59 | prefix = prefix .. " [" .. level_name .. "]" 60 | 61 | local message = "" 62 | for i, part in ipairs(message_parts) do 63 | if i > 1 then 64 | message = message .. " " 65 | end 66 | if type(part) == "table" or type(part) == "boolean" then 67 | message = message .. vim.inspect(part) 68 | else 69 | message = message .. tostring(part) 70 | end 71 | end 72 | 73 | -- Wrap all vim.notify and nvim_echo calls in vim.schedule to avoid 74 | -- "nvim_echo must not be called in a fast event context" errors 75 | vim.schedule(function() 76 | if level == M.levels.ERROR then 77 | vim.notify(prefix .. " " .. message, vim.log.levels.ERROR, { title = "ClaudeCode Error" }) 78 | elseif level == M.levels.WARN then 79 | vim.notify(prefix .. " " .. message, vim.log.levels.WARN, { title = "ClaudeCode Warning" }) 80 | else 81 | -- For INFO, DEBUG, TRACE, use nvim_echo to avoid flooding notifications, 82 | -- to make them appear in :messages 83 | vim.api.nvim_echo({ { prefix .. " " .. message, "Normal" } }, true, {}) 84 | end 85 | end) 86 | end 87 | 88 | ---Error level logging 89 | ---@param component string|nil Optional component/module name. 90 | ---@param ... any Varargs representing parts of the message. 91 | function M.error(component, ...) 92 | if type(component) ~= "string" then 93 | log(M.levels.ERROR, nil, { component, ... }) 94 | else 95 | log(M.levels.ERROR, component, { ... }) 96 | end 97 | end 98 | 99 | ---Warn level logging 100 | ---@param component string|nil Optional component/module name. 101 | ---@param ... any Varargs representing parts of the message. 102 | function M.warn(component, ...) 103 | if type(component) ~= "string" then 104 | log(M.levels.WARN, nil, { component, ... }) 105 | else 106 | log(M.levels.WARN, component, { ... }) 107 | end 108 | end 109 | 110 | ---Info level logging 111 | ---@param component string|nil Optional component/module name. 112 | ---@param ... any Varargs representing parts of the message. 113 | function M.info(component, ...) 114 | if type(component) ~= "string" then 115 | log(M.levels.INFO, nil, { component, ... }) 116 | else 117 | log(M.levels.INFO, component, { ... }) 118 | end 119 | end 120 | 121 | ---Check if a specific log level is enabled 122 | ---@param level_name ClaudeCodeLogLevel The level name ("error", "warn", "info", "debug", "trace") 123 | ---@return boolean enabled Whether the level is enabled 124 | function M.is_level_enabled(level_name) 125 | local level_value = level_values[level_name] 126 | if not level_value then 127 | return false 128 | end 129 | return level_value <= current_log_level_value 130 | end 131 | 132 | ---Debug level logging 133 | ---@param component string|nil Optional component/module name. 134 | ---@param ... any Varargs representing parts of the message. 135 | function M.debug(component, ...) 136 | if type(component) ~= "string" then 137 | log(M.levels.DEBUG, nil, { component, ... }) 138 | else 139 | log(M.levels.DEBUG, component, { ... }) 140 | end 141 | end 142 | 143 | ---Trace level logging 144 | ---@param component string|nil Optional component/module name. 145 | ---@param ... any Varargs representing parts of the message. 146 | function M.trace(component, ...) 147 | if type(component) ~= "string" then 148 | log(M.levels.TRACE, nil, { component, ... }) 149 | else 150 | log(M.levels.TRACE, component, { ... }) 151 | end 152 | end 153 | 154 | return M 155 | -------------------------------------------------------------------------------- /lua/claudecode/tools/close_tab.lua: -------------------------------------------------------------------------------- 1 | --- Tool implementation for closing a buffer by its name. 2 | 3 | -- Note: Schema defined but not used since this tool is internal 4 | -- local schema = { 5 | -- description = "Close a tab/buffer by its tab name", 6 | -- inputSchema = { 7 | -- type = "object", 8 | -- properties = { 9 | -- tab_name = { 10 | -- type = "string", 11 | -- description = "Name of the tab to close", 12 | -- }, 13 | -- }, 14 | -- required = { "tab_name" }, 15 | -- additionalProperties = false, 16 | -- ["$schema"] = "http://json-schema.org/draft-07/schema#", 17 | -- }, 18 | -- } 19 | 20 | ---Handles the close_tab tool invocation. 21 | ---Closes a tab/buffer by its tab name. 22 | ---@param params {tab_name: string} The input parameters for the tool 23 | ---@return table success A result message indicating success 24 | local function handler(params) 25 | local log_module_ok, log = pcall(require, "claudecode.logger") 26 | if not log_module_ok then 27 | return { 28 | code = -32603, -- Internal error 29 | message = "Internal error", 30 | data = "Failed to load logger module", 31 | } 32 | end 33 | 34 | log.debug("close_tab handler called with params: " .. vim.inspect(params)) 35 | 36 | if not params.tab_name then 37 | log.error("Missing required parameter: tab_name") 38 | return { 39 | code = -32602, -- Invalid params 40 | message = "Invalid params", 41 | data = "Missing required parameter: tab_name", 42 | } 43 | end 44 | 45 | -- Extract the actual file name from the tab name 46 | -- Tab name format: "✻ [Claude Code] README.md (e18e1e) ⧉" 47 | -- We need to extract "README.md" or the full path 48 | local tab_name = params.tab_name 49 | log.debug("Attempting to close tab: " .. tab_name) 50 | 51 | -- Check if this is a diff tab (contains ✻ and ⧉ markers) 52 | if tab_name:match("✻") and tab_name:match("⧉") then 53 | log.debug("Detected diff tab - closing diff view") 54 | 55 | -- Try to close the diff 56 | local diff_module_ok, diff = pcall(require, "claudecode.diff") 57 | if diff_module_ok and diff.close_diff_by_tab_name then 58 | local closed = diff.close_diff_by_tab_name(tab_name) 59 | if closed then 60 | log.debug("Successfully closed diff for tab: " .. tab_name) 61 | return { 62 | content = { 63 | { 64 | type = "text", 65 | text = "TAB_CLOSED", 66 | }, 67 | }, 68 | } 69 | else 70 | log.debug("Diff not found for tab: " .. tab_name) 71 | return { 72 | content = { 73 | { 74 | type = "text", 75 | text = "TAB_CLOSED", 76 | }, 77 | }, 78 | } 79 | end 80 | else 81 | log.error("Failed to load diff module or close_diff_by_tab_name not available") 82 | return { 83 | content = { 84 | { 85 | type = "text", 86 | text = "TAB_CLOSED", 87 | }, 88 | }, 89 | } 90 | end 91 | end 92 | 93 | -- Try to find buffer by the tab name first 94 | local bufnr = vim.fn.bufnr(tab_name) 95 | 96 | if bufnr == -1 then 97 | -- If not found, try to extract filename from the tab name 98 | -- Look for pattern like "filename.ext" in the tab name 99 | local filename = tab_name:match("([%w%.%-_]+%.[%w]+)") 100 | if filename then 101 | log.debug("Extracted filename from tab name: " .. filename) 102 | -- Try to find buffer by filename 103 | for _, buf in ipairs(vim.api.nvim_list_bufs()) do 104 | local buf_name = vim.api.nvim_buf_get_name(buf) 105 | if buf_name:match(filename .. "$") then 106 | bufnr = buf 107 | log.debug("Found buffer by filename match: " .. buf_name) 108 | break 109 | end 110 | end 111 | end 112 | end 113 | 114 | if bufnr == -1 then 115 | -- If buffer not found, the tab might already be closed - treat as success 116 | log.debug("Buffer not found for tab (already closed?): " .. tab_name) 117 | return { 118 | content = { 119 | { 120 | type = "text", 121 | text = "TAB_CLOSED", 122 | }, 123 | }, 124 | } 125 | end 126 | 127 | local success, err = pcall(vim.api.nvim_buf_delete, bufnr, { force = false }) 128 | 129 | if not success then 130 | log.error("Failed to close buffer: " .. tostring(err)) 131 | return { 132 | code = -32000, 133 | message = "Buffer operation error", 134 | data = "Failed to close buffer for tab " .. tab_name .. ": " .. tostring(err), 135 | } 136 | end 137 | 138 | log.info("Successfully closed tab: " .. tab_name) 139 | 140 | -- Return MCP-compliant format matching VS Code extension 141 | return { 142 | content = { 143 | { 144 | type = "text", 145 | text = "TAB_CLOSED", 146 | }, 147 | }, 148 | } 149 | end 150 | 151 | return { 152 | name = "close_tab", 153 | schema = nil, -- Internal tool - must remain as requested by user 154 | handler = handler, 155 | } 156 | -------------------------------------------------------------------------------- /scripts/research_messages.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # research_messages.sh - Script to analyze JSON-RPC messages from Claude Code VSCode extension 4 | # This script connects to a running Claude Code VSCode instance and logs all JSON-RPC messages 5 | # for analysis. 6 | 7 | set -e 8 | 9 | # Source the library 10 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 11 | # shellcheck source=./lib_claude.sh 12 | source "$SCRIPT_DIR/lib_claude.sh" 13 | 14 | # Configuration 15 | TIMEOUT=30 # How long to listen for messages (seconds) 16 | LOG_FILE="claude_messages.jsonl" # File to log all JSON-RPC messages 17 | PRETTY_LOG="claude_messages_pretty.txt" # File to log prettified messages 18 | WEBSOCKET_PORT="" # Will be detected automatically 19 | 20 | # Parse command line arguments 21 | while [[ $# -gt 0 ]]; do 22 | case $1 in 23 | -p | --port) 24 | WEBSOCKET_PORT="$2" 25 | shift 2 26 | ;; 27 | -t | --timeout) 28 | TIMEOUT="$2" 29 | shift 2 30 | ;; 31 | -l | --log) 32 | LOG_FILE="$2" 33 | shift 2 34 | ;; 35 | *) 36 | echo "Unknown option: $1" 37 | echo "Usage: $0 [-p|--port PORT] [-t|--timeout SECONDS] [-l|--log LOGFILE]" 38 | exit 1 39 | ;; 40 | esac 41 | done 42 | 43 | # Get WebSocket port if not provided 44 | if [ -z "$WEBSOCKET_PORT" ]; then 45 | # Use library function to find the port 46 | WEBSOCKET_PORT=$(find_claude_lockfile) 47 | echo "Found Claude Code running on port: $WEBSOCKET_PORT" 48 | fi 49 | 50 | # Create directory for logs 51 | LOG_DIR=$(dirname "$LOG_FILE") 52 | if [ ! -d "$LOG_DIR" ] && [ "$LOG_DIR" != "." ]; then 53 | mkdir -p "$LOG_DIR" 54 | fi 55 | 56 | # MCP connection message 57 | MCP_CONNECT='{ 58 | "jsonrpc": "2.0", 59 | "id": "1", 60 | "method": "mcp.connect", 61 | "params": { 62 | "protocolVersion": "2024-11-05", 63 | "capabilities": { 64 | "tools": {} 65 | }, 66 | "clientInfo": { 67 | "name": "research-client", 68 | "version": "1.0.0" 69 | } 70 | } 71 | }' 72 | 73 | # Function to send a test message and see what happens 74 | send_test_message() { 75 | local method="$1" 76 | local params="$2" 77 | local id="$3" 78 | 79 | local message="{\"jsonrpc\":\"2.0\",\"id\":\"$id\",\"method\":\"$method\",\"params\":$params}" 80 | echo "Sending test message: $message" 81 | echo "$message" | websocat -n1 "ws://127.0.0.1:$WEBSOCKET_PORT/" 82 | echo 83 | } 84 | 85 | # Clear previous log files 86 | true >"$LOG_FILE" 87 | true >"$PRETTY_LOG" 88 | 89 | # Now that we have the port, display connection information 90 | echo "Connecting to WebSocket server at ws://127.0.0.1:$WEBSOCKET_PORT/" 91 | echo 92 | echo "Will listen for $TIMEOUT seconds and log messages to $LOG_FILE" 93 | echo "A prettified version will be written to $PRETTY_LOG" 94 | echo 95 | 96 | # Use websocat to connect and log all messages 97 | ( 98 | # First send the connection message 99 | echo "$MCP_CONNECT" 100 | 101 | # Keep the connection open 102 | sleep "$TIMEOUT" 103 | ) | websocat "ws://127.0.0.1:$WEBSOCKET_PORT/" | tee >(cat >"$LOG_FILE") | while IFS= read -r line; do 104 | # Print each message with timestamp 105 | echo "[$(date +"%H:%M:%S")] Received: $line" 106 | 107 | # Prettify JSON and append to pretty log file 108 | echo -e "\n--- Message at $(date +"%H:%M:%S") ---" >>"$PRETTY_LOG" 109 | echo "$line" | jq '.' >>"$PRETTY_LOG" 2>/dev/null || echo "Invalid JSON: $line" >>"$PRETTY_LOG" 110 | 111 | # Analyze message type 112 | if echo "$line" | grep -q '"method":'; then 113 | method=$(echo "$line" | jq -r '.method // "unknown"' 2>/dev/null) 114 | echo " → Method: $method" 115 | fi 116 | 117 | if echo "$line" | grep -q '"id":'; then 118 | id=$(echo "$line" | jq -r '.id // "unknown"' 2>/dev/null) 119 | echo " → ID: $id" 120 | 121 | # If this is a response to our connection message, try sending a test method 122 | if [ "$id" = "1" ]; then 123 | echo "Received connection response. Let's try some test methods..." 124 | sleep 2 125 | 126 | # Test a tool invocation 127 | send_test_message "tools/call" '{"name":"getCurrentSelection","arguments":{}}' "2" 128 | 129 | # Try another tool invocation 130 | send_test_message "tools/call" '{"name":"getActiveFilePath","arguments":{}}' "3" 131 | 132 | # Try tools/list method 133 | send_test_message "tools/list" '{}' "4" 134 | 135 | # Try various method patterns 136 | send_test_message "listTools" '{}' "5" 137 | send_test_message "mcp.tools.list" '{}' "6" 138 | 139 | # Try pinging the server 140 | send_test_message "ping" '{}' "7" 141 | fi 142 | fi 143 | done 144 | 145 | echo 146 | echo "Listening completed after $TIMEOUT seconds." 147 | echo "Logged all messages to $LOG_FILE" 148 | echo "Prettified messages saved to $PRETTY_LOG" 149 | echo 150 | echo "Message summary:" 151 | 152 | # Generate a summary of message methods and IDs 153 | echo "Message methods found:" 154 | grep -o '"method":"[^"]*"' "$LOG_FILE" | sort | uniq -c | sort -nr 155 | 156 | echo 157 | echo "Message IDs found:" 158 | grep -o '"id":"[^"]*"' "$LOG_FILE" | sort | uniq -c | sort -nr 159 | 160 | # Now analyze the messages that were sent 161 | echo 162 | echo "Analyzing messages..." 163 | 164 | # Count number of selection_changed events 165 | selection_changed_count=$(grep -c '"method":"selection_changed"' "$LOG_FILE") 166 | echo "selection_changed notifications: $selection_changed_count" 167 | 168 | # Check if we received any tool responses 169 | tool_responses=$(grep -c '"id":"[23]"' "$LOG_FILE") 170 | echo "Tool responses: $tool_responses" 171 | 172 | echo 173 | echo "Research complete. See $PRETTY_LOG for detailed message content." 174 | -------------------------------------------------------------------------------- /tests/unit/directory_at_mention_spec.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: globals expect 2 | require("tests.busted_setup") 3 | 4 | describe("Directory At Mention Functionality", function() 5 | local integrations 6 | local visual_commands 7 | local mock_vim 8 | 9 | local function setup_mocks() 10 | package.loaded["claudecode.integrations"] = nil 11 | package.loaded["claudecode.visual_commands"] = nil 12 | package.loaded["claudecode.logger"] = nil 13 | 14 | -- Mock logger 15 | package.loaded["claudecode.logger"] = { 16 | debug = function() end, 17 | warn = function() end, 18 | error = function() end, 19 | } 20 | 21 | mock_vim = { 22 | fn = { 23 | isdirectory = function(path) 24 | if string.match(path, "/lua$") or string.match(path, "/tests$") or string.match(path, "src") then 25 | return 1 26 | end 27 | return 0 28 | end, 29 | getcwd = function() 30 | return "/Users/test/project" 31 | end, 32 | mode = function() 33 | return "n" 34 | end, 35 | }, 36 | api = { 37 | nvim_get_current_win = function() 38 | return 1002 39 | end, 40 | nvim_get_mode = function() 41 | return { mode = "n" } 42 | end, 43 | }, 44 | bo = { filetype = "neo-tree" }, 45 | } 46 | 47 | _G.vim = mock_vim 48 | end 49 | 50 | before_each(function() 51 | setup_mocks() 52 | end) 53 | 54 | describe("directory handling in integrations", function() 55 | before_each(function() 56 | integrations = require("claudecode.integrations") 57 | end) 58 | 59 | it("should return directory paths from neo-tree", function() 60 | local mock_state = { 61 | tree = { 62 | get_node = function() 63 | return { 64 | type = "directory", 65 | path = "/Users/test/project/lua", 66 | } 67 | end, 68 | }, 69 | } 70 | 71 | package.loaded["neo-tree.sources.manager"] = { 72 | get_state = function() 73 | return mock_state 74 | end, 75 | } 76 | 77 | local files, err = integrations._get_neotree_selection() 78 | 79 | expect(err).to_be_nil() 80 | expect(files).to_be_table() 81 | expect(#files).to_be(1) 82 | expect(files[1]).to_be("/Users/test/project/lua") 83 | end) 84 | 85 | it("should return directory paths from nvim-tree", function() 86 | package.loaded["nvim-tree.api"] = { 87 | tree = { 88 | get_node_under_cursor = function() 89 | return { 90 | type = "directory", 91 | absolute_path = "/Users/test/project/tests", 92 | } 93 | end, 94 | }, 95 | marks = { 96 | list = function() 97 | return {} 98 | end, 99 | }, 100 | } 101 | 102 | mock_vim.bo.filetype = "NvimTree" 103 | 104 | local files, err = integrations._get_nvim_tree_selection() 105 | 106 | expect(err).to_be_nil() 107 | expect(files).to_be_table() 108 | expect(#files).to_be(1) 109 | expect(files[1]).to_be("/Users/test/project/tests") 110 | end) 111 | end) 112 | 113 | describe("visual commands directory handling", function() 114 | before_each(function() 115 | visual_commands = require("claudecode.visual_commands") 116 | end) 117 | 118 | it("should include directories in visual selections", function() 119 | local visual_data = { 120 | tree_state = { 121 | tree = { 122 | get_node = function(self, line) 123 | if line == 1 then 124 | return { 125 | type = "file", 126 | path = "/Users/test/project/init.lua", 127 | get_depth = function() 128 | return 2 129 | end, 130 | } 131 | elseif line == 2 then 132 | return { 133 | type = "directory", 134 | path = "/Users/test/project/lua", 135 | get_depth = function() 136 | return 2 137 | end, 138 | } 139 | end 140 | return nil 141 | end, 142 | }, 143 | }, 144 | tree_type = "neo-tree", 145 | start_pos = 1, 146 | end_pos = 2, 147 | } 148 | 149 | local files, err = visual_commands.get_files_from_visual_selection(visual_data) 150 | 151 | expect(err).to_be_nil() 152 | expect(files).to_be_table() 153 | expect(#files).to_be(2) 154 | expect(files[1]).to_be("/Users/test/project/init.lua") 155 | expect(files[2]).to_be("/Users/test/project/lua") 156 | end) 157 | 158 | it("should respect depth protection for directories", function() 159 | local visual_data = { 160 | tree_state = { 161 | tree = { 162 | get_node = function(line) 163 | if line == 1 then 164 | return { 165 | type = "directory", 166 | path = "/Users/test/project", 167 | get_depth = function() 168 | return 1 169 | end, 170 | } 171 | end 172 | return nil 173 | end, 174 | }, 175 | }, 176 | tree_type = "neo-tree", 177 | start_pos = 1, 178 | end_pos = 1, 179 | } 180 | 181 | local files, err = visual_commands.get_files_from_visual_selection(visual_data) 182 | 183 | expect(err).to_be_nil() 184 | expect(files).to_be_table() 185 | expect(#files).to_be(0) -- Root-level directory should be skipped 186 | end) 187 | end) 188 | end) 189 | -------------------------------------------------------------------------------- /scripts/claude_shell_helpers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Source this file in your .zshrc or .bashrc to add Claude Code helper functions 3 | # to your interactive shell 4 | # 5 | # Example usage: 6 | # echo 'source /path/to/claude_shell_helpers.sh' >> ~/.zshrc 7 | # 8 | # Then in your shell: 9 | # $ claude_port 10 | # $ claude_get_selection 11 | # $ claude_open_file /path/to/file.txt 12 | 13 | # Get the script's directory, handling sourced scripts 14 | if [[ -n $0 && $0 != "-bash" && $0 != "-zsh" ]]; then 15 | CLAUDE_LIB_DIR="$(dirname "$(realpath "$0")")" 16 | else 17 | # Fallback when being sourced in a shell 18 | CLAUDE_LIB_DIR="${HOME}/.claude/bin" 19 | fi 20 | 21 | # Source the main library 22 | # shellcheck source=./lib_claude.sh 23 | source "$CLAUDE_LIB_DIR/lib_claude.sh" 24 | 25 | # Set default log directory relative to home 26 | export CLAUDE_LOG_DIR="$HOME/.claude/logs" 27 | mkdir -p "$CLAUDE_LOG_DIR" 2>/dev/null 28 | 29 | # Function to get and print Claude Code port 30 | claude_port() { 31 | find_claude_lockfile 32 | } 33 | 34 | # Function to get websocket URL 35 | claude_ws_url() { 36 | get_claude_ws_url 37 | } 38 | 39 | # Function to check if Claude Code is running 40 | claude_running() { 41 | if claude_is_running; then 42 | local port 43 | port=$(find_claude_lockfile 2>/dev/null) 44 | echo "Claude Code is running (port: $port)" 45 | return 0 46 | else 47 | echo "Claude Code is not running" 48 | return 1 49 | fi 50 | } 51 | 52 | # Function to get current selection 53 | claude_get_selection() { 54 | if claude_is_running; then 55 | local response 56 | response=$(get_current_selection) 57 | 58 | # Pretty print the whole response if -v/--verbose flag is provided 59 | if [[ $1 == "-v" || $1 == "--verbose" ]]; then 60 | echo "$response" | jq . 61 | return 62 | fi 63 | 64 | # Otherwise just output the selection text 65 | local selection 66 | selection=$(echo "$response" | jq -r '.result.text // "No text selected"') 67 | if [[ $selection != "No text selected" && $selection != "null" ]]; then 68 | echo "$selection" 69 | else 70 | echo "No text currently selected" 71 | fi 72 | else 73 | echo "Error: Claude Code is not running" >&2 74 | return 1 75 | fi 76 | } 77 | 78 | # Function to open a file in Claude Code 79 | claude_open_file() { 80 | if [ -z "$1" ]; then 81 | echo "Usage: claude_open_file " >&2 82 | return 1 83 | fi 84 | 85 | local file_path="$1" 86 | 87 | # Convert to absolute path if relative 88 | if [[ $file_path != /* ]]; then 89 | file_path="$(realpath "$file_path" 2>/dev/null)" 90 | if ! realpath "$file_path" &>/dev/null; then 91 | echo "Error: Invalid file path" >&2 92 | return 1 93 | fi 94 | fi 95 | 96 | if [ ! -f "$file_path" ]; then 97 | echo "Error: File does not exist: $file_path" >&2 98 | return 1 99 | fi 100 | 101 | if claude_is_running; then 102 | open_file "$file_path" >/dev/null 103 | echo "Opened: $file_path" 104 | else 105 | echo "Error: Claude Code is not running" >&2 106 | return 1 107 | fi 108 | } 109 | 110 | # Function to list available tools 111 | claude_list_tools() { 112 | if claude_is_running; then 113 | local response 114 | response=$(list_claude_tools) 115 | 116 | # Pretty print the whole response if -v/--verbose flag is provided 117 | if [[ $1 == "-v" || $1 == "--verbose" ]]; then 118 | echo "$response" | jq . 119 | return 120 | fi 121 | 122 | # Otherwise just list tool names 123 | echo "$response" | jq -r '.result.tools[].name' 2>/dev/null | sort 124 | else 125 | echo "Error: Claude Code is not running" >&2 126 | return 1 127 | fi 128 | } 129 | 130 | # Function to send a custom message 131 | claude_send() { 132 | if [ $# -lt 2 ]; then 133 | echo "Usage: claude_send [request_id]" >&2 134 | echo "Example: claude_send 'getCurrentSelection' '{}' 'my-id'" >&2 135 | return 1 136 | fi 137 | 138 | local method="$1" 139 | local params="$2" 140 | local id="${3:-$(uuidgen)}" 141 | 142 | if claude_is_running; then 143 | local message 144 | message=$(create_message "$method" "$params" "$id") 145 | 146 | local response 147 | response=$(send_claude_message "$message") 148 | echo "$response" | jq . 149 | else 150 | echo "Error: Claude Code is not running" >&2 151 | return 1 152 | fi 153 | } 154 | 155 | # Launch the interactive tool 156 | claude_interactive() { 157 | "$CLAUDE_LIB_DIR/claude_interactive.sh" 158 | } 159 | 160 | # Print help for shell functions 161 | claude_help() { 162 | cat < - Open a file in Claude Code 172 | claude_list_tools - List available tools 173 | claude_list_tools -v - List tools with details (verbose) 174 | claude_send [id] - Send a custom JSON-RPC message 175 | claude_interactive - Launch the interactive CLI 176 | claude_help - Show this help message 177 | 178 | Examples: 179 | $ claude_port 180 | $ claude_get_selection 181 | $ claude_open_file ~/project/src/main.js 182 | $ claude_send 'getCurrentSelection' '{}' 183 | EOL 184 | } 185 | 186 | # Check if sourced in an interactive shell 187 | if [[ $- == *i* ]]; then 188 | echo "Claude Code shell helpers loaded. Type 'claude_help' for available commands." 189 | fi 190 | -------------------------------------------------------------------------------- /tests/unit/opendiff_blocking_spec.lua: -------------------------------------------------------------------------------- 1 | -- Unit test for openDiff blocking behavior 2 | -- This test directly calls the openDiff handler to verify blocking behavior 3 | 4 | describe("openDiff blocking behavior", function() 5 | local open_diff_module 6 | local mock_logger 7 | 8 | before_each(function() 9 | -- Set up minimal vim mock 10 | require("tests.helpers.setup") 11 | 12 | -- Mock logger 13 | mock_logger = { 14 | debug = spy.new(function() end), 15 | error = spy.new(function() end), 16 | info = spy.new(function() end), 17 | } 18 | 19 | package.loaded["claudecode.logger"] = mock_logger 20 | 21 | -- Mock diff module to prevent loading issues 22 | package.loaded["claudecode.diff"] = { 23 | open_diff_blocking = function() 24 | error("This should not be called in coroutine context test") 25 | end, 26 | } 27 | 28 | -- Load the module under test 29 | open_diff_module = require("claudecode.tools.open_diff") 30 | end) 31 | 32 | after_each(function() 33 | -- Clean up 34 | package.loaded["claudecode.logger"] = nil 35 | package.loaded["claudecode.tools.open_diff"] = nil 36 | package.loaded["claudecode.diff"] = nil 37 | end) 38 | 39 | it("should require coroutine context", function() 40 | -- Test that openDiff fails when not in coroutine context 41 | local params = { 42 | old_file_path = "/tmp/test.txt", 43 | new_file_path = "/tmp/test.txt", 44 | new_file_contents = "test content", 45 | tab_name = "test tab", 46 | } 47 | 48 | -- This should error because we're not in a coroutine 49 | local success, err = pcall(open_diff_module.handler, params) 50 | 51 | assert.is_false(success) 52 | assert.is_table(err) 53 | assert.equals(-32000, err.code) 54 | assert.matches("coroutine context", err.data) 55 | end) 56 | 57 | it("should block in coroutine context", function() 58 | -- Create test file 59 | local test_file = "/tmp/opendiff_test.txt" 60 | local file = io.open(test_file, "w") 61 | file:write("original content\n") 62 | file:close() 63 | 64 | local params = { 65 | old_file_path = test_file, 66 | new_file_path = test_file, 67 | new_file_contents = "modified content\n", 68 | tab_name = "✻ [Test] test.txt ⧉", 69 | } 70 | 71 | local co_finished = false 72 | local error_occurred = false 73 | local test_error = nil 74 | 75 | -- Create coroutine that calls openDiff 76 | local co = coroutine.create(function() 77 | local success, result = pcall(open_diff_module.handler, params) 78 | if not success then 79 | error_occurred = true 80 | test_error = result 81 | end 82 | co_finished = true 83 | end) 84 | 85 | -- Start the coroutine 86 | local success = coroutine.resume(co) 87 | assert.is_true(success) 88 | 89 | -- In test environment, the diff setup may fail due to missing vim APIs 90 | -- This is expected and doesn't indicate a problem with the blocking logic 91 | if error_occurred then 92 | -- Verify it's failing for expected reasons (missing vim APIs, not logic errors) 93 | assert.is_true(type(test_error) == "table" or type(test_error) == "string") 94 | -- Test passes - openDiff correctly requires full vim environment 95 | else 96 | -- If it didn't error, it should be blocking 97 | assert.is_false(co_finished, "Coroutine should not finish immediately - it should block") 98 | assert.equals("suspended", coroutine.status(co)) 99 | -- Test passes - openDiff properly blocks in coroutine context 100 | end 101 | 102 | -- Check that some logging occurred (openDiff attempts logging even if it fails) 103 | -- In test environment, this might not always be called due to early failures 104 | if not error_occurred then 105 | assert.spy(mock_logger.debug).was_called() 106 | end 107 | 108 | -- Cleanup 109 | os.remove(test_file) 110 | end) 111 | 112 | it("should handle file not found error", function() 113 | local params = { 114 | old_file_path = "/nonexistent/file.txt", 115 | new_file_path = "/nonexistent/file.txt", 116 | new_file_contents = "content", 117 | tab_name = "test tab", 118 | } 119 | 120 | local co = coroutine.create(function() 121 | return open_diff_module.handler(params) 122 | end) 123 | 124 | local success, err = coroutine.resume(co) 125 | 126 | -- Should fail because file doesn't exist 127 | assert.is_false(success) 128 | assert.is_table(err) 129 | assert.equals(-32000, err.code) -- Error gets wrapped by open_diff_blocking 130 | -- The exact error message may vary depending on where it fails in the test environment 131 | assert.is_true( 132 | err.message == "Error setting up diff" 133 | or err.message == "Internal server error" 134 | or err.message == "Error opening blocking diff" 135 | ) 136 | end) 137 | 138 | it("should validate required parameters", function() 139 | local test_cases = { 140 | {}, -- empty params 141 | { old_file_path = "/tmp/test.txt" }, -- missing new_file_path 142 | { old_file_path = "/tmp/test.txt", new_file_path = "/tmp/test.txt" }, -- missing new_file_contents 143 | { old_file_path = "/tmp/test.txt", new_file_path = "/tmp/test.txt", new_file_contents = "content" }, -- missing tab_name 144 | } 145 | 146 | for i, params in ipairs(test_cases) do 147 | local co = coroutine.create(function() 148 | return open_diff_module.handler(params) 149 | end) 150 | 151 | local success, err = coroutine.resume(co) 152 | 153 | assert.is_false(success, "Test case " .. i .. " should fail validation") 154 | assert.is_table(err, "Test case " .. i .. " should return structured error") 155 | assert.equals(-32602, err.code, "Test case " .. i .. " should return invalid params error") 156 | end 157 | end) 158 | end) 159 | -------------------------------------------------------------------------------- /tests/unit/logger_spec.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: globals expect 2 | require("tests.busted_setup") 3 | 4 | describe("Logger", function() 5 | local logger 6 | local original_vim_schedule 7 | local original_vim_notify 8 | local original_nvim_echo 9 | local scheduled_calls = {} 10 | local notify_calls = {} 11 | local echo_calls = {} 12 | 13 | local function setup() 14 | package.loaded["claudecode.logger"] = nil 15 | 16 | -- Mock vim.schedule to track calls 17 | original_vim_schedule = vim.schedule 18 | vim.schedule = function(fn) 19 | table.insert(scheduled_calls, fn) 20 | -- Immediately execute the function for testing 21 | fn() 22 | end 23 | 24 | -- Mock vim.notify to track calls 25 | original_vim_notify = vim.notify 26 | vim.notify = function(msg, level, opts) 27 | table.insert(notify_calls, { msg = msg, level = level, opts = opts }) 28 | end 29 | 30 | -- Mock nvim_echo to track calls 31 | original_nvim_echo = vim.api.nvim_echo 32 | vim.api.nvim_echo = function(chunks, history, opts) 33 | table.insert(echo_calls, { chunks = chunks, history = history, opts = opts }) 34 | end 35 | 36 | logger = require("claudecode.logger") 37 | 38 | -- Set log level to TRACE to enable all logging levels for testing 39 | logger.setup({ log_level = "trace" }) 40 | end 41 | 42 | local function teardown() 43 | vim.schedule = original_vim_schedule 44 | vim.notify = original_vim_notify 45 | vim.api.nvim_echo = original_nvim_echo 46 | scheduled_calls = {} 47 | notify_calls = {} 48 | echo_calls = {} 49 | end 50 | 51 | before_each(function() 52 | setup() 53 | end) 54 | 55 | after_each(function() 56 | teardown() 57 | end) 58 | 59 | describe("error logging", function() 60 | it("should wrap error calls in vim.schedule", function() 61 | logger.error("test", "error message") 62 | 63 | -- Should have made one scheduled call 64 | expect(#scheduled_calls).to_be(1) 65 | 66 | -- Should have called vim.notify with error level 67 | expect(#notify_calls).to_be(1) 68 | expect(notify_calls[1].level).to_be(vim.log.levels.ERROR) 69 | assert_contains(notify_calls[1].msg, "error message") 70 | end) 71 | 72 | it("should handle error calls without component", function() 73 | logger.error("error message") 74 | 75 | expect(#scheduled_calls).to_be(1) 76 | expect(#notify_calls).to_be(1) 77 | assert_contains(notify_calls[1].msg, "error message") 78 | end) 79 | end) 80 | 81 | describe("warn logging", function() 82 | it("should wrap warn calls in vim.schedule", function() 83 | logger.warn("test", "warning message") 84 | 85 | -- Should have made one scheduled call 86 | expect(#scheduled_calls).to_be(1) 87 | 88 | -- Should have called vim.notify with warn level 89 | expect(#notify_calls).to_be(1) 90 | expect(notify_calls[1].level).to_be(vim.log.levels.WARN) 91 | assert_contains(notify_calls[1].msg, "warning message") 92 | end) 93 | 94 | it("should handle warn calls without component", function() 95 | logger.warn("warning message") 96 | 97 | expect(#scheduled_calls).to_be(1) 98 | expect(#notify_calls).to_be(1) 99 | assert_contains(notify_calls[1].msg, "warning message") 100 | end) 101 | end) 102 | 103 | describe("info logging", function() 104 | it("should wrap info calls in vim.schedule", function() 105 | logger.info("test", "info message") 106 | 107 | -- Should have made one scheduled call 108 | expect(#scheduled_calls).to_be(1) 109 | 110 | -- Should have called nvim_echo instead of notify 111 | expect(#echo_calls).to_be(1) 112 | expect(#notify_calls).to_be(0) 113 | assert_contains(echo_calls[1].chunks[1][1], "info message") 114 | end) 115 | end) 116 | 117 | describe("debug logging", function() 118 | it("should wrap debug calls in vim.schedule", function() 119 | logger.debug("test", "debug message") 120 | 121 | -- Should have made one scheduled call 122 | expect(#scheduled_calls).to_be(1) 123 | 124 | -- Should have called nvim_echo instead of notify 125 | expect(#echo_calls).to_be(1) 126 | expect(#notify_calls).to_be(0) 127 | assert_contains(echo_calls[1].chunks[1][1], "debug message") 128 | end) 129 | end) 130 | 131 | describe("trace logging", function() 132 | it("should wrap trace calls in vim.schedule", function() 133 | logger.trace("test", "trace message") 134 | 135 | -- Should have made one scheduled call 136 | expect(#scheduled_calls).to_be(1) 137 | 138 | -- Should have called nvim_echo instead of notify 139 | expect(#echo_calls).to_be(1) 140 | expect(#notify_calls).to_be(0) 141 | assert_contains(echo_calls[1].chunks[1][1], "trace message") 142 | end) 143 | end) 144 | 145 | describe("fast event context safety", function() 146 | it("should not call vim API functions directly", function() 147 | -- Simulate a fast event context by removing the mocked functions 148 | -- and ensuring no direct calls are made 149 | local direct_notify_called = false 150 | local direct_echo_called = false 151 | 152 | vim.notify = function() 153 | direct_notify_called = true 154 | end 155 | 156 | vim.api.nvim_echo = function() 157 | direct_echo_called = true 158 | end 159 | 160 | vim.schedule = function(fn) 161 | -- Don't execute the function, just verify it was scheduled 162 | table.insert(scheduled_calls, fn) 163 | end 164 | 165 | logger.error("test", "error in fast context") 166 | logger.warn("test", "warn in fast context") 167 | logger.info("test", "info in fast context") 168 | 169 | -- All should be scheduled, none should be called directly 170 | expect(#scheduled_calls).to_be(3) 171 | expect(direct_notify_called).to_be_false() 172 | expect(direct_echo_called).to_be_false() 173 | end) 174 | end) 175 | end) 176 | -------------------------------------------------------------------------------- /tests/unit/tools/get_current_selection_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") -- Ensure test helpers are loaded 2 | 3 | describe("Tool: get_current_selection", function() 4 | local get_current_selection_handler 5 | local mock_selection_module 6 | 7 | before_each(function() 8 | -- Mock the selection module 9 | mock_selection_module = { 10 | get_latest_selection = spy.new(function() 11 | -- Default behavior: no selection 12 | return nil 13 | end), 14 | } 15 | package.loaded["claudecode.selection"] = mock_selection_module 16 | 17 | -- Reset and require the module under test 18 | package.loaded["claudecode.tools.get_current_selection"] = nil 19 | get_current_selection_handler = require("claudecode.tools.get_current_selection").handler 20 | 21 | -- Mock vim.api and vim.json functions that might be called by the fallback if no selection 22 | _G.vim = _G.vim or {} 23 | _G.vim.api = _G.vim.api or {} 24 | _G.vim.json = _G.vim.json or {} 25 | _G.vim.api.nvim_get_current_buf = spy.new(function() 26 | return 1 27 | end) 28 | _G.vim.api.nvim_buf_get_name = spy.new(function(bufnr) 29 | if bufnr == 1 then 30 | return "/current/file.lua" 31 | end 32 | return "unknown_buffer" 33 | end) 34 | _G.vim.json.encode = spy.new(function(data, opts) 35 | return require("tests.busted_setup").json_encode(data) 36 | end) 37 | end) 38 | 39 | after_each(function() 40 | package.loaded["claudecode.selection"] = nil 41 | package.loaded["claudecode.tools.get_current_selection"] = nil 42 | _G.vim.api.nvim_get_current_buf = nil 43 | _G.vim.api.nvim_buf_get_name = nil 44 | _G.vim.json.encode = nil 45 | end) 46 | 47 | it("should return an empty selection structure if no selection is available", function() 48 | mock_selection_module.get_latest_selection = spy.new(function() 49 | return nil 50 | end) 51 | 52 | local success, result = pcall(get_current_selection_handler, {}) 53 | expect(success).to_be_true() 54 | expect(result).to_be_table() 55 | expect(result.content).to_be_table() 56 | expect(result.content[1]).to_be_table() 57 | expect(result.content[1].type).to_be("text") 58 | 59 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 60 | expect(parsed_result.success).to_be_true() -- New success field 61 | expect(parsed_result.text).to_be("") 62 | expect(parsed_result.filePath).to_be("/current/file.lua") 63 | expect(parsed_result.selection.isEmpty).to_be_true() 64 | expect(parsed_result.selection.start.line).to_be(0) -- Default empty selection 65 | expect(parsed_result.selection.start.character).to_be(0) 66 | assert.spy(mock_selection_module.get_latest_selection).was_called() 67 | end) 68 | 69 | it("should return the selection data from claudecode.selection if available", function() 70 | local mock_sel_data = { 71 | text = "selected text", 72 | filePath = "/path/to/file.lua", 73 | fileUrl = "file:///path/to/file.lua", 74 | selection = { 75 | start = { line = 10, character = 4 }, 76 | ["end"] = { line = 10, character = 17 }, 77 | isEmpty = false, 78 | }, 79 | } 80 | mock_selection_module.get_latest_selection = spy.new(function() 81 | return mock_sel_data 82 | end) 83 | 84 | local success, result = pcall(get_current_selection_handler, {}) 85 | expect(success).to_be_true() 86 | expect(result).to_be_table() 87 | expect(result.content).to_be_table() 88 | expect(result.content[1]).to_be_table() 89 | expect(result.content[1].type).to_be("text") 90 | 91 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 92 | -- Should return the selection data with success field added 93 | local expected_result = vim.tbl_extend("force", mock_sel_data, { success = true }) 94 | assert.are.same(expected_result, parsed_result) 95 | assert.spy(mock_selection_module.get_latest_selection).was_called() 96 | end) 97 | 98 | it("should return error format when no active editor is found", function() 99 | mock_selection_module.get_latest_selection = spy.new(function() 100 | return nil 101 | end) 102 | 103 | -- Mock empty buffer name to simulate no active editor 104 | _G.vim.api.nvim_buf_get_name = spy.new(function() 105 | return "" 106 | end) 107 | 108 | local success, result = pcall(get_current_selection_handler, {}) 109 | expect(success).to_be_true() 110 | expect(result).to_be_table() 111 | expect(result.content).to_be_table() 112 | expect(result.content[1]).to_be_table() 113 | expect(result.content[1].type).to_be("text") 114 | 115 | local parsed_result = require("tests.busted_setup").json_decode(result.content[1].text) 116 | expect(parsed_result.success).to_be_false() 117 | expect(parsed_result.message).to_be("No active editor found") 118 | -- Should not have other fields when success is false 119 | expect(parsed_result.text).to_be_nil() 120 | expect(parsed_result.filePath).to_be_nil() 121 | expect(parsed_result.selection).to_be_nil() 122 | end) 123 | 124 | it("should handle pcall failure when requiring selection module", function() 125 | -- Simulate require failing 126 | package.loaded["claudecode.selection"] = nil -- Ensure it's not cached 127 | local original_require = _G.require 128 | _G.require = function(mod_name) 129 | if mod_name == "claudecode.selection" then 130 | error("Simulated require failure for claudecode.selection") 131 | end 132 | return original_require(mod_name) 133 | end 134 | 135 | local success, err = pcall(get_current_selection_handler, {}) 136 | _G.require = original_require -- Restore original require 137 | 138 | expect(success).to_be_false() 139 | expect(err).to_be_table() 140 | expect(err.code).to_be(-32000) 141 | assert_contains(err.message, "Internal server error") 142 | assert_contains(err.data, "Failed to load selection module") 143 | end) 144 | end) 145 | -------------------------------------------------------------------------------- /scripts/test_opendiff.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lua 2 | 3 | -- Test script that mimics Claude Code CLI sending an openDiff tool call 4 | -- This helps automate testing of the openDiff blocking behavior 5 | 6 | local socket = require("socket") 7 | local json = require("json") or require("cjson") or require("dkjson") 8 | 9 | -- Configuration 10 | local HOST = "127.0.0.1" 11 | local PORT = nil -- Will discover from lock file 12 | local LOCK_FILE_PATH = os.getenv("HOME") .. "/.claude/ide/" 13 | 14 | -- Discover port from lock files 15 | local function discover_port() 16 | local handle = io.popen("ls " .. LOCK_FILE_PATH .. "*.lock 2>/dev/null") 17 | if not handle then 18 | print("❌ No lock files found in " .. LOCK_FILE_PATH) 19 | return nil 20 | end 21 | 22 | local result = handle:read("*a") 23 | handle:close() 24 | 25 | if result == "" then 26 | print("❌ No lock files found") 27 | return nil 28 | end 29 | 30 | -- Extract port from first lock file name 31 | local lock_file = result:match("([^\n]+)") 32 | local port = lock_file:match("(%d+)%.lock") 33 | 34 | if port then 35 | print("✅ Discovered port " .. port .. " from " .. lock_file) 36 | return tonumber(port) 37 | else 38 | print("❌ Could not parse port from lock file: " .. lock_file) 39 | return nil 40 | end 41 | end 42 | 43 | -- Read README.md content 44 | local function read_readme() 45 | local file = io.open("README.md", "r") 46 | if not file then 47 | print("❌ Could not read README.md - run this script from the project root") 48 | os.exit(1) 49 | end 50 | 51 | local content = file:read("*a") 52 | file:close() 53 | 54 | -- Simulate adding a license link (append at end) 55 | local modified_content = content .. "\n## License\n\n[MIT](LICENSE)\n" 56 | 57 | return content, modified_content 58 | end 59 | 60 | -- Create WebSocket handshake 61 | local function websocket_handshake(sock) 62 | local key = "dGhlIHNhbXBsZSBub25jZQ==" 63 | local request = string.format( 64 | "GET / HTTP/1.1\r\n" 65 | .. "Host: %s:%d\r\n" 66 | .. "Upgrade: websocket\r\n" 67 | .. "Connection: Upgrade\r\n" 68 | .. "Sec-WebSocket-Key: %s\r\n" 69 | .. "Sec-WebSocket-Version: 13\r\n" 70 | .. "\r\n", 71 | HOST, 72 | PORT, 73 | key 74 | ) 75 | 76 | sock:send(request) 77 | 78 | local response = sock:receive("*l") 79 | if not response or not response:match("101 Switching Protocols") then 80 | print("❌ WebSocket handshake failed") 81 | return false 82 | end 83 | 84 | -- Read remaining headers 85 | repeat 86 | local line = sock:receive("*l") 87 | until line == "" 88 | 89 | print("✅ WebSocket handshake successful") 90 | return true 91 | end 92 | 93 | -- Send WebSocket frame 94 | local function send_frame(sock, payload) 95 | local len = #payload 96 | local frame = string.char(0x81) -- Text frame, FIN=1 97 | 98 | if len < 126 then 99 | frame = frame .. string.char(len) 100 | elseif len < 65536 then 101 | frame = frame .. string.char(126) .. string.char(math.floor(len / 256)) .. string.char(len % 256) 102 | else 103 | error("Payload too large") 104 | end 105 | 106 | frame = frame .. payload 107 | sock:send(frame) 108 | end 109 | 110 | -- Main test function 111 | local function test_opendiff() 112 | print("🧪 Starting openDiff automation test...") 113 | 114 | -- Step 1: Discover port 115 | PORT = discover_port() 116 | if not PORT then 117 | print("❌ Make sure Neovim with claudecode.nvim is running first") 118 | os.exit(1) 119 | end 120 | 121 | -- Step 2: Read README content 122 | local old_content, new_content = read_readme() 123 | print("✅ Loaded README.md (" .. #old_content .. " chars)") 124 | 125 | -- Step 3: Connect to WebSocket 126 | local sock = socket.tcp() 127 | sock:settimeout(5) 128 | 129 | local success, err = sock:connect(HOST, PORT) 130 | if not success then 131 | print("❌ Could not connect to " .. HOST .. ":" .. PORT .. " - " .. (err or "unknown error")) 132 | os.exit(1) 133 | end 134 | 135 | print("✅ Connected to WebSocket server") 136 | 137 | -- Step 4: WebSocket handshake 138 | if not websocket_handshake(sock) then 139 | os.exit(1) 140 | end 141 | 142 | -- Step 5: Send openDiff tool call 143 | local tool_call = { 144 | jsonrpc = "2.0", 145 | id = 1, 146 | method = "tools/call", 147 | params = { 148 | name = "openDiff", 149 | arguments = { 150 | old_file_path = os.getenv("PWD") .. "/README.md", 151 | new_file_path = os.getenv("PWD") .. "/README.md", 152 | new_file_contents = new_content, 153 | tab_name = "✻ [Test] README.md (automated) ⧉", 154 | }, 155 | }, 156 | } 157 | 158 | local json_message = json.encode(tool_call) 159 | print("📤 Sending openDiff tool call...") 160 | send_frame(sock, json_message) 161 | 162 | -- Step 6: Wait for response with timeout 163 | print("⏳ Waiting for response (should block until user interaction)...") 164 | sock:settimeout(30) -- 30 second timeout 165 | 166 | local response = sock:receive("*l") 167 | if response then 168 | print("📥 Received immediate response (BAD - should block):") 169 | print(response) 170 | else 171 | print("✅ No immediate response - tool is properly blocking!") 172 | print("👉 Now go to Neovim and interact with the diff (save or close)") 173 | print("👉 Press Ctrl+C here when done testing") 174 | 175 | -- Keep listening for the eventual response 176 | sock:settimeout(0) -- Non-blocking 177 | repeat 178 | local data = sock:receive("*l") 179 | if data then 180 | print("📥 Final response received:") 181 | print(data) 182 | break 183 | end 184 | socket.sleep(0.1) 185 | until false 186 | end 187 | 188 | sock:close() 189 | end 190 | 191 | -- Check dependencies 192 | if not socket then 193 | print("❌ luasocket not found. Install with: luarocks install luasocket") 194 | os.exit(1) 195 | end 196 | 197 | if not json then 198 | print("❌ JSON library not found. Install with: luarocks install dkjson") 199 | os.exit(1) 200 | end 201 | 202 | -- Run the test 203 | test_opendiff() 204 | -------------------------------------------------------------------------------- /tests/unit/tree_send_visual_spec.lua: -------------------------------------------------------------------------------- 1 | require("tests.busted_setup") 2 | require("tests.mocks.vim") 3 | 4 | describe("ClaudeCodeSend visual selection in tree buffers", function() 5 | local original_require 6 | local claudecode 7 | local command_callback 8 | 9 | local mock_server 10 | local mock_logger 11 | local mock_visual_commands 12 | local mock_integrations 13 | 14 | before_each(function() 15 | -- Reset package cache 16 | package.loaded["claudecode"] = nil 17 | package.loaded["claudecode.visual_commands"] = nil 18 | package.loaded["claudecode.integrations"] = nil 19 | package.loaded["claudecode.server.init"] = nil 20 | package.loaded["claudecode.lockfile"] = nil 21 | package.loaded["claudecode.config"] = nil 22 | package.loaded["claudecode.logger"] = nil 23 | package.loaded["claudecode.diff"] = nil 24 | 25 | -- Mocks 26 | mock_server = { 27 | broadcast = spy.new(function() 28 | return true 29 | end), 30 | start = function() 31 | return true, 12345 32 | end, 33 | stop = function() 34 | return true 35 | end, 36 | } 37 | 38 | mock_logger = { 39 | setup = function() end, 40 | debug = function() end, 41 | error = function() end, 42 | warn = function() end, 43 | } 44 | 45 | local visual_data = { 46 | tree_state = {}, 47 | tree_type = "neo-tree", 48 | start_pos = 10, 49 | end_pos = 12, 50 | } 51 | 52 | mock_visual_commands = { 53 | -- Force the command to take the visual path by immediately invoking the visual handler 54 | create_visual_command_wrapper = function(_normal_handler, visual_handler) 55 | return function() 56 | return visual_handler(visual_data) 57 | end 58 | end, 59 | get_files_from_visual_selection = spy.new(function(data) 60 | assert.is_truthy(data) 61 | return { 62 | "/proj/a.lua", 63 | "/proj/b.lua", 64 | "/proj/dir", 65 | }, nil 66 | end), 67 | } 68 | 69 | mock_integrations = { 70 | get_selected_files_from_tree = spy.new(function() 71 | -- Should not be called when visual selection produces files 72 | return {}, "should_not_be_called" 73 | end), 74 | _get_mini_files_selection_with_range = function() 75 | return {}, "unused" 76 | end, 77 | } 78 | 79 | -- Mock vim API and environment 80 | _G.vim.api.nvim_create_user_command = spy.new(function(name, callback, opts) 81 | if name == "ClaudeCodeSend" then 82 | command_callback = callback 83 | end 84 | end) 85 | _G.vim.api.nvim_create_augroup = spy.new(function() 86 | return 1 87 | end) 88 | _G.vim.api.nvim_create_autocmd = spy.new(function() 89 | return 1 90 | end) 91 | _G.vim.api.nvim_replace_termcodes = function(s) 92 | return s 93 | end 94 | _G.vim.api.nvim_feedkeys = function() end 95 | 96 | _G.vim.fn.mode = function() 97 | return "v" 98 | end 99 | _G.vim.fn.line = function(_) 100 | return 10 101 | end 102 | _G.vim.bo = { filetype = "neo-tree" } 103 | 104 | -- Mock require 105 | original_require = _G.require 106 | _G.require = function(module) 107 | if module == "claudecode.logger" then 108 | return mock_logger 109 | elseif module == "claudecode.visual_commands" then 110 | return mock_visual_commands 111 | elseif module == "claudecode.integrations" then 112 | return mock_integrations 113 | elseif module == "claudecode.server.init" then 114 | return { 115 | get_status = function() 116 | return { running = true, client_count = 1 } 117 | end, 118 | } 119 | elseif module == "claudecode.lockfile" then 120 | return { 121 | create = function() 122 | return true, "/tmp/mock.lock", "auth" 123 | end, 124 | remove = function() 125 | return true 126 | end, 127 | generate_auth_token = function() 128 | return "auth" 129 | end, 130 | } 131 | elseif module == "claudecode.config" then 132 | return { 133 | apply = function(opts) 134 | return opts or { log_level = "info" } 135 | end, 136 | } 137 | elseif module == "claudecode.diff" then 138 | return { setup = function() end } 139 | elseif module == "claudecode.terminal" then 140 | return { 141 | setup = function() end, 142 | open = function() end, 143 | ensure_visible = function() end, 144 | } 145 | else 146 | return original_require(module) 147 | end 148 | end 149 | 150 | -- Load plugin and setup 151 | claudecode = require("claudecode") 152 | claudecode.setup({ auto_start = false }) 153 | claudecode.state.server = mock_server 154 | claudecode.state.port = 12345 155 | -- Ensure immediate broadcast path in tests 156 | claudecode.state.config.disable_broadcast_debouncing = true 157 | 158 | -- Spy on send_at_mention to count file sends without relying on broadcast internals 159 | claudecode.send_at_mention = spy.new(function() 160 | return true 161 | end) 162 | end) 163 | 164 | after_each(function() 165 | _G.require = original_require 166 | end) 167 | 168 | it("uses visual selection path and broadcasts all files", function() 169 | assert.is_function(command_callback) 170 | 171 | -- Invoke the command (our wrapper will dispatch to visual handler) 172 | command_callback({}) 173 | 174 | -- Should use visual selection, not fallback integrations 175 | assert.spy(mock_visual_commands.get_files_from_visual_selection).was_called() 176 | assert.spy(mock_integrations.get_selected_files_from_tree).was_not_called() 177 | 178 | -- 3 files should be sent via send_at_mention 179 | assert.spy(claudecode.send_at_mention).was_called() 180 | local call_count = #claudecode.send_at_mention.calls 181 | assert.is_true(call_count == 3, "Expected 3 sends, got " .. tostring(call_count)) 182 | end) 183 | end) 184 | -------------------------------------------------------------------------------- /tests/config_test.lua: -------------------------------------------------------------------------------- 1 | -- Simple config module tests that don't rely on the vim API 2 | 3 | _G.vim = { ---@type vim_global_api 4 | schedule_wrap = function(fn) 5 | return fn 6 | end, 7 | deepcopy = function(t) 8 | -- Basic deepcopy implementation for testing purposes 9 | local copy = {} 10 | for k, v in pairs(t) do 11 | if type(v) == "table" then 12 | copy[k] = _G.vim.deepcopy(v) 13 | else 14 | copy[k] = v 15 | end 16 | end 17 | return copy 18 | end, 19 | notify = function(_, _, _) end, 20 | log = { 21 | levels = { 22 | NONE = 0, 23 | ERROR = 1, 24 | WARN = 2, 25 | INFO = 3, 26 | DEBUG = 4, 27 | TRACE = 5, 28 | }, 29 | }, 30 | o = { ---@type vim_options_table 31 | columns = 80, 32 | lines = 24, 33 | }, 34 | bo = setmetatable({}, { -- Mock for vim.bo and vim.bo[bufnr] 35 | __index = function(t, k) 36 | if type(k) == "number" then 37 | if not t[k] then 38 | t[k] = {} ---@type vim_buffer_options_table 39 | end 40 | return t[k] 41 | end 42 | return nil 43 | end, 44 | __newindex = function(t, k, v) 45 | if type(k) == "number" then 46 | -- For mock simplicity, allows direct setting for vim.bo[bufnr].opt = val or similar assignments. 47 | if not t[k] then 48 | t[k] = {} 49 | end 50 | rawset(t[k], v) -- Assuming v is the option name if k is bufnr, this is simplified 51 | else 52 | rawset(t, k, v) 53 | end 54 | end, 55 | }), ---@type vim_bo_proxy 56 | diagnostic = { ---@type vim_diagnostic_module 57 | get = function() 58 | return {} 59 | end, 60 | -- Add other vim.diagnostic functions as needed for these tests 61 | }, 62 | empty_dict = function() 63 | return {} 64 | end, 65 | 66 | tbl_deep_extend = function(behavior, ...) 67 | local result = {} 68 | local tables = { ... } 69 | 70 | for _, tbl in ipairs(tables) do 71 | for k, v in pairs(tbl) do 72 | if type(v) == "table" and type(result[k]) == "table" then 73 | result[k] = _G.vim.tbl_deep_extend(behavior, result[k], v) 74 | else 75 | result[k] = v 76 | end 77 | end 78 | end 79 | 80 | return result 81 | end, 82 | cmd = function() end, ---@type fun(command: string):nil 83 | api = {}, ---@type table 84 | fn = { ---@type vim_fn_table 85 | mode = function() 86 | return "n" 87 | end, 88 | delete = function(_, _) 89 | return 0 90 | end, 91 | filereadable = function(_) 92 | return 1 93 | end, 94 | fnamemodify = function(fname, _) 95 | return fname 96 | end, 97 | expand = function(s, _) 98 | return s 99 | end, 100 | getcwd = function() 101 | return "/mock/cwd" 102 | end, 103 | mkdir = function(_, _, _) 104 | return 1 105 | end, 106 | buflisted = function(_) 107 | return 1 108 | end, 109 | bufname = function(_) 110 | return "mockbuffer" 111 | end, 112 | bufnr = function(_) 113 | return 1 114 | end, 115 | win_getid = function() 116 | return 1 117 | end, 118 | win_gotoid = function(_) 119 | return true 120 | end, 121 | line = function(_) 122 | return 1 123 | end, 124 | col = function(_) 125 | return 1 126 | end, 127 | virtcol = function(_) 128 | return 1 129 | end, 130 | getpos = function(_) 131 | return { 0, 1, 1, 0 } 132 | end, 133 | setpos = function(_, _) 134 | return true 135 | end, 136 | tempname = function() 137 | return "/tmp/mocktemp" 138 | end, 139 | globpath = function(_, _) 140 | return "" 141 | end, 142 | termopen = function(_, _) 143 | return 0 144 | end, 145 | stdpath = function(_) 146 | return "/mock/stdpath" 147 | end, 148 | json_encode = function(_) 149 | return "{}" 150 | end, 151 | json_decode = function(_) 152 | return {} 153 | end, 154 | }, 155 | fs = { remove = function() end }, ---@type vim_fs_module 156 | } 157 | 158 | describe("Config module", function() 159 | local config 160 | 161 | setup(function() 162 | -- Reset the module to ensure a clean state for each test 163 | package.loaded["claudecode.config"] = nil 164 | 165 | config = require("claudecode.config") 166 | end) 167 | 168 | it("should have default values", function() 169 | assert(type(config.defaults) == "table") 170 | assert(type(config.defaults.port_range) == "table") 171 | assert(type(config.defaults.port_range.min) == "number") 172 | assert(type(config.defaults.port_range.max) == "number") 173 | assert(type(config.defaults.auto_start) == "boolean") 174 | assert(type(config.defaults.log_level) == "string") 175 | assert(type(config.defaults.track_selection) == "boolean") 176 | end) 177 | 178 | it("should apply and validate user configuration", function() 179 | local user_config = { 180 | terminal_cmd = "toggleterm", 181 | log_level = "debug", 182 | track_selection = false, 183 | models = { 184 | { name = "Claude Opus 4 (Latest)", value = "claude-opus-4-20250514" }, 185 | { name = "Claude Sonnet 4 (Latest)", value = "claude-sonnet-4-20250514" }, 186 | }, 187 | } 188 | 189 | local success, final_config = pcall(function() 190 | return config.apply(user_config) 191 | end) 192 | 193 | assert(success == true) 194 | assert(final_config.env ~= nil) -- Should inherit default empty table 195 | assert(type(final_config.env) == "table") 196 | end) 197 | 198 | it("should merge user config with defaults", function() 199 | local user_config = { 200 | auto_start = true, 201 | log_level = "debug", 202 | } 203 | 204 | local merged_config = config.apply(user_config) 205 | 206 | assert(merged_config.auto_start == true) 207 | assert("debug" == merged_config.log_level) 208 | assert(config.defaults.port_range.min == merged_config.port_range.min) 209 | assert(config.defaults.track_selection == merged_config.track_selection) 210 | end) 211 | end) 212 | -------------------------------------------------------------------------------- /lua/claudecode/types.lua: -------------------------------------------------------------------------------- 1 | ---@meta 2 | ---@brief [[ 3 | --- Centralized type definitions for ClaudeCode.nvim public API. 4 | --- This module contains all user-facing types and configuration structures. 5 | ---@brief ]] 6 | ---@module 'claudecode.types' 7 | 8 | -- Version information type 9 | ---@class ClaudeCodeVersion 10 | ---@field major integer 11 | ---@field minor integer 12 | ---@field patch integer 13 | ---@field prerelease? string 14 | ---@field string fun(self: ClaudeCodeVersion): string 15 | 16 | -- Diff behavior configuration 17 | ---@class ClaudeCodeDiffOptions 18 | ---@field layout ClaudeCodeDiffLayout 19 | ---@field open_in_new_tab boolean Open diff in a new tab (false = use current tab) 20 | ---@field keep_terminal_focus boolean Keep focus in terminal after opening diff 21 | ---@field hide_terminal_in_new_tab boolean Hide Claude terminal in newly created diff tab 22 | ---@field on_new_file_reject ClaudeCodeNewFileRejectBehavior Behavior when rejecting a new-file diff 23 | 24 | -- Model selection option 25 | ---@class ClaudeCodeModelOption 26 | ---@field name string 27 | ---@field value string 28 | 29 | -- Log level type alias 30 | ---@alias ClaudeCodeLogLevel "trace"|"debug"|"info"|"warn"|"error" 31 | 32 | -- Diff layout type alias 33 | ---@alias ClaudeCodeDiffLayout "vertical"|"horizontal" 34 | 35 | -- Behavior when rejecting new-file diffs 36 | ---@alias ClaudeCodeNewFileRejectBehavior "keep_empty"|"close_window" 37 | 38 | -- Terminal split side positioning 39 | ---@alias ClaudeCodeSplitSide "left"|"right" 40 | 41 | -- In-tree terminal provider names 42 | ---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"|"external"|"none" 43 | 44 | -- Terminal provider-specific options 45 | ---@class ClaudeCodeTerminalProviderOptions 46 | ---@field external_terminal_cmd string|(fun(cmd: string, env: table): string)|table|nil Command for external terminal (string template with %s or function) 47 | 48 | -- Working directory resolution context and provider 49 | ---@class ClaudeCodeCwdContext 50 | ---@field file string|nil -- absolute path of current buffer file (if any) 51 | ---@field file_dir string|nil -- directory of current buffer file (if any) 52 | ---@field cwd string -- current Neovim working directory 53 | 54 | ---@alias ClaudeCodeCwdProvider fun(ctx: ClaudeCodeCwdContext): string|nil 55 | 56 | -- @ mention queued for Claude Code 57 | ---@class ClaudeCodeMention 58 | ---@field file_path string The absolute file path to mention 59 | ---@field start_line number? Optional start line (0-indexed for Claude compatibility) 60 | ---@field end_line number? Optional end line (0-indexed for Claude compatibility) 61 | ---@field timestamp number Creation timestamp from vim.loop.now() for expiry tracking 62 | 63 | -- Terminal provider interface 64 | ---@class ClaudeCodeTerminalProvider 65 | ---@field setup fun(config: ClaudeCodeTerminalConfig) 66 | ---@field open fun(cmd_string: string, env_table: table, config: ClaudeCodeTerminalConfig, focus: boolean?) 67 | ---@field close fun() 68 | ---@field toggle fun(cmd_string: string, env_table: table, effective_config: ClaudeCodeTerminalConfig) 69 | ---@field simple_toggle fun(cmd_string: string, env_table: table, effective_config: ClaudeCodeTerminalConfig) 70 | ---@field focus_toggle fun(cmd_string: string, env_table: table, effective_config: ClaudeCodeTerminalConfig) 71 | ---@field get_active_bufnr fun(): number? 72 | ---@field is_available fun(): boolean 73 | ---@field ensure_visible? function 74 | ---@field _get_terminal_for_test fun(): table? 75 | 76 | -- Terminal configuration 77 | ---@class ClaudeCodeTerminalConfig 78 | ---@field split_side ClaudeCodeSplitSide 79 | ---@field split_width_percentage number 80 | ---@field provider ClaudeCodeTerminalProviderName|ClaudeCodeTerminalProvider 81 | ---@field show_native_term_exit_tip boolean 82 | ---@field terminal_cmd string? 83 | ---@field provider_opts ClaudeCodeTerminalProviderOptions? 84 | ---@field auto_close boolean 85 | ---@field env table 86 | ---@field snacks_win_opts snacks.win.Config 87 | ---@field cwd string|nil -- static working directory for Claude terminal 88 | ---@field git_repo_cwd boolean|nil -- use git root of current file/cwd as working directory 89 | ---@field cwd_provider? ClaudeCodeCwdProvider -- custom function to compute working directory 90 | 91 | -- Port range configuration 92 | ---@class ClaudeCodePortRange 93 | ---@field min integer 94 | ---@field max integer 95 | 96 | -- Server status information 97 | ---@class ClaudeCodeServerStatus 98 | ---@field running boolean 99 | ---@field port integer? 100 | ---@field client_count integer 101 | ---@field clients? table 102 | 103 | -- Main configuration structure 104 | ---@class ClaudeCodeConfig 105 | ---@field port_range ClaudeCodePortRange 106 | ---@field auto_start boolean 107 | ---@field terminal_cmd string|nil 108 | ---@field env table 109 | ---@field log_level ClaudeCodeLogLevel 110 | ---@field track_selection boolean 111 | ---@field focus_after_send boolean 112 | ---@field visual_demotion_delay_ms number 113 | ---@field connection_wait_delay number 114 | ---@field connection_timeout number 115 | ---@field queue_timeout number 116 | ---@field diff_opts ClaudeCodeDiffOptions 117 | ---@field models ClaudeCodeModelOption[] 118 | ---@field disable_broadcast_debouncing? boolean 119 | ---@field enable_broadcast_debouncing_in_tests? boolean 120 | ---@field terminal ClaudeCodeTerminalConfig? 121 | 122 | ---@class (partial) PartialClaudeCodeConfig: ClaudeCodeConfig 123 | 124 | -- Server interface for main module 125 | ---@class ClaudeCodeServerFacade 126 | ---@field start fun(config: ClaudeCodeConfig, auth_token: string|nil): (success: boolean, port_or_error: number|string) 127 | ---@field stop fun(): (success: boolean, error_message: string?) 128 | ---@field broadcast fun(method: string, params: table?): boolean 129 | ---@field get_status fun(): ClaudeCodeServerStatus 130 | 131 | -- Main module state 132 | ---@class ClaudeCodeState 133 | ---@field config ClaudeCodeConfig 134 | ---@field server ClaudeCodeServerFacade|nil 135 | ---@field port integer|nil 136 | ---@field auth_token string|nil 137 | ---@field initialized boolean 138 | ---@field mention_queue ClaudeCodeMention[] 139 | ---@field mention_timer uv.uv_timer_t? -- (compatible with vim.loop timer) 140 | ---@field connection_timer uv.uv_timer_t? -- (compatible with vim.loop timer) 141 | 142 | -- This module only defines types, no runtime functionality 143 | return {} 144 | -------------------------------------------------------------------------------- /fixtures/mini-files/lua/plugins/mini-files.lua: -------------------------------------------------------------------------------- 1 | return { 2 | "echasnovski/mini.files", 3 | version = false, 4 | config = function() 5 | require("mini.files").setup({ 6 | -- Customization of shown content 7 | content = { 8 | -- Predicate for which file system entries to show 9 | filter = nil, 10 | -- What prefix to show to the left of file system entry 11 | prefix = nil, 12 | -- In which order to show file system entries 13 | sort = nil, 14 | }, 15 | 16 | -- Module mappings created only inside explorer. 17 | -- Use `''` (empty string) to not create one. 18 | mappings = { 19 | close = "q", 20 | go_in = "l", 21 | go_in_plus = "L", 22 | go_out = "h", 23 | go_out_plus = "H", 24 | reset = "", 25 | reveal_cwd = "@", 26 | show_help = "g?", 27 | synchronize = "=", 28 | trim_left = "<", 29 | trim_right = ">", 30 | }, 31 | 32 | -- General options 33 | options = { 34 | -- Whether to delete permanently or move into module-specific trash 35 | permanent_delete = true, 36 | -- Whether to use for editing directories 37 | use_as_default_explorer = true, 38 | }, 39 | 40 | -- Customization of explorer windows 41 | windows = { 42 | -- Maximum number of windows to show side by side 43 | max_number = math.huge, 44 | -- Whether to show preview of file/directory under cursor 45 | preview = false, 46 | -- Width of focused window 47 | width_focus = 50, 48 | -- Width of non-focused window 49 | width_nofocus = 15, 50 | -- Width of preview window 51 | width_preview = 25, 52 | }, 53 | }) 54 | 55 | -- Global keybindings for mini.files 56 | vim.keymap.set("n", "e", function() 57 | require("mini.files").open() 58 | end, { desc = "Open mini.files (current dir)" }) 59 | 60 | vim.keymap.set("n", "E", function() 61 | require("mini.files").open(vim.api.nvim_buf_get_name(0)) 62 | end, { desc = "Open mini.files (current file)" }) 63 | 64 | vim.keymap.set("n", "-", function() 65 | require("mini.files").open() 66 | end, { desc = "Open parent directory" }) 67 | 68 | -- Mini.files specific keybindings and autocommands 69 | vim.api.nvim_create_autocmd("User", { 70 | pattern = "MiniFilesBufferCreate", 71 | callback = function(args) 72 | local buf_id = args.data.buf_id 73 | 74 | -- Add buffer-local keybindings 75 | vim.keymap.set("n", "", function() 76 | -- Split window and open file 77 | local cur_target = require("mini.files").get_fs_entry() 78 | if cur_target and cur_target.fs_type == "file" then 79 | require("mini.files").close() 80 | vim.cmd("split " .. cur_target.path) 81 | end 82 | end, { buffer = buf_id, desc = "Split and open file" }) 83 | 84 | vim.keymap.set("n", "", function() 85 | -- Vertical split and open file 86 | local cur_target = require("mini.files").get_fs_entry() 87 | if cur_target and cur_target.fs_type == "file" then 88 | require("mini.files").close() 89 | vim.cmd("vsplit " .. cur_target.path) 90 | end 91 | end, { buffer = buf_id, desc = "Vertical split and open file" }) 92 | 93 | vim.keymap.set("n", "", function() 94 | -- Open in new tab 95 | local cur_target = require("mini.files").get_fs_entry() 96 | if cur_target and cur_target.fs_type == "file" then 97 | require("mini.files").close() 98 | vim.cmd("tabnew " .. cur_target.path) 99 | end 100 | end, { buffer = buf_id, desc = "Open in new tab" }) 101 | 102 | -- Create new file/directory 103 | vim.keymap.set("n", "a", function() 104 | local cur_target = require("mini.files").get_fs_entry() 105 | local path = cur_target and cur_target.path or require("mini.files").get_explorer_state().cwd 106 | local new_name = vim.fn.input("Create: " .. path .. "/") 107 | if new_name and new_name ~= "" then 108 | if new_name:sub(-1) == "/" then 109 | -- Create directory 110 | vim.fn.mkdir(path .. "/" .. new_name, "p") 111 | else 112 | -- Create file 113 | local new_file = io.open(path .. "/" .. new_name, "w") 114 | if new_file then 115 | new_file:close() 116 | end 117 | end 118 | require("mini.files").refresh() 119 | end 120 | end, { buffer = buf_id, desc = "Create new file/directory" }) 121 | 122 | -- Rename file/directory 123 | vim.keymap.set("n", "r", function() 124 | local cur_target = require("mini.files").get_fs_entry() 125 | if cur_target then 126 | local old_name = vim.fn.fnamemodify(cur_target.path, ":t") 127 | local new_name = vim.fn.input("Rename to: ", old_name) 128 | if new_name and new_name ~= "" and new_name ~= old_name then 129 | local new_path = vim.fn.fnamemodify(cur_target.path, ":h") .. "/" .. new_name 130 | os.rename(cur_target.path, new_path) 131 | require("mini.files").refresh() 132 | end 133 | end 134 | end, { buffer = buf_id, desc = "Rename file/directory" }) 135 | 136 | -- Delete file/directory 137 | vim.keymap.set("n", "d", function() 138 | local cur_target = require("mini.files").get_fs_entry() 139 | if cur_target then 140 | local confirm = vim.fn.confirm("Delete " .. cur_target.path .. "?", "&Yes\n&No", 2) 141 | if confirm == 1 then 142 | if cur_target.fs_type == "directory" then 143 | vim.fn.delete(cur_target.path, "rf") 144 | else 145 | vim.fn.delete(cur_target.path) 146 | end 147 | require("mini.files").refresh() 148 | end 149 | end 150 | end, { buffer = buf_id, desc = "Delete file/directory" }) 151 | end, 152 | }) 153 | 154 | -- Auto-close mini.files when it's the last window 155 | vim.api.nvim_create_autocmd("User", { 156 | pattern = "MiniFilesBufferUpdate", 157 | callback = function() 158 | if vim.bo.filetype == "minifiles" then 159 | -- Check if this is the only window left 160 | local windows = vim.api.nvim_list_wins() 161 | local minifiles_windows = 0 162 | for _, win in ipairs(windows) do 163 | local buf = vim.api.nvim_win_get_buf(win) 164 | if vim.api.nvim_buf_get_option(buf, "filetype") == "minifiles" then 165 | minifiles_windows = minifiles_windows + 1 166 | end 167 | end 168 | 169 | if #windows == minifiles_windows then 170 | vim.cmd("quit") 171 | end 172 | end 173 | end, 174 | }) 175 | end, 176 | } 177 | -------------------------------------------------------------------------------- /tests/unit/oil_integration_spec.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: globals expect 2 | require("tests.busted_setup") 3 | 4 | describe("oil.nvim integration", function() 5 | local integrations 6 | local mock_vim 7 | 8 | local function setup_mocks() 9 | package.loaded["claudecode.integrations"] = nil 10 | package.loaded["claudecode.visual_commands"] = nil 11 | package.loaded["claudecode.logger"] = nil 12 | 13 | -- Mock logger 14 | package.loaded["claudecode.logger"] = { 15 | debug = function() end, 16 | warn = function() end, 17 | error = function() end, 18 | } 19 | 20 | mock_vim = { 21 | fn = { 22 | mode = function() 23 | return "n" -- Default to normal mode 24 | end, 25 | line = function(mark) 26 | if mark == "'<" then 27 | return 2 28 | elseif mark == "'>" then 29 | return 4 30 | end 31 | return 1 32 | end, 33 | }, 34 | api = { 35 | nvim_get_current_buf = function() 36 | return 1 37 | end, 38 | nvim_win_get_cursor = function() 39 | return { 4, 0 } 40 | end, 41 | nvim_get_mode = function() 42 | return { mode = "n" } 43 | end, 44 | }, 45 | bo = { filetype = "oil" }, 46 | } 47 | 48 | _G.vim = mock_vim 49 | end 50 | 51 | before_each(function() 52 | setup_mocks() 53 | integrations = require("claudecode.integrations") 54 | end) 55 | 56 | describe("_get_oil_selection", function() 57 | it("should get single file under cursor in normal mode", function() 58 | local mock_oil = { 59 | get_cursor_entry = function() 60 | return { type = "file", name = "main.lua" } 61 | end, 62 | get_current_dir = function(bufnr) 63 | return "/Users/test/project/" 64 | end, 65 | } 66 | 67 | package.loaded["oil"] = mock_oil 68 | 69 | local files, err = integrations._get_oil_selection() 70 | 71 | expect(err).to_be_nil() 72 | expect(files).to_be_table() 73 | expect(#files).to_be(1) 74 | expect(files[1]).to_be("/Users/test/project/main.lua") 75 | end) 76 | 77 | it("should get directory under cursor in normal mode", function() 78 | local mock_oil = { 79 | get_cursor_entry = function() 80 | return { type = "directory", name = "src" } 81 | end, 82 | get_current_dir = function(bufnr) 83 | return "/Users/test/project/" 84 | end, 85 | } 86 | 87 | package.loaded["oil"] = mock_oil 88 | 89 | local files, err = integrations._get_oil_selection() 90 | 91 | expect(err).to_be_nil() 92 | expect(files).to_be_table() 93 | expect(#files).to_be(1) 94 | expect(files[1]).to_be("/Users/test/project/src/") 95 | end) 96 | 97 | it("should skip parent directory entries", function() 98 | local mock_oil = { 99 | get_cursor_entry = function() 100 | return { type = "directory", name = ".." } 101 | end, 102 | get_current_dir = function(bufnr) 103 | return "/Users/test/project/" 104 | end, 105 | } 106 | 107 | package.loaded["oil"] = mock_oil 108 | 109 | local files, err = integrations._get_oil_selection() 110 | 111 | expect(err).to_be("No file found under cursor") 112 | expect(files).to_be_table() 113 | expect(#files).to_be(0) 114 | end) 115 | 116 | it("should handle symbolic links", function() 117 | local mock_oil = { 118 | get_cursor_entry = function() 119 | return { type = "link", name = "linked_file.lua" } 120 | end, 121 | get_current_dir = function(bufnr) 122 | return "/Users/test/project/" 123 | end, 124 | } 125 | 126 | package.loaded["oil"] = mock_oil 127 | 128 | local files, err = integrations._get_oil_selection() 129 | 130 | expect(err).to_be_nil() 131 | expect(files).to_be_table() 132 | expect(#files).to_be(1) 133 | expect(files[1]).to_be("/Users/test/project/linked_file.lua") 134 | end) 135 | 136 | it("should handle visual mode selection", function() 137 | -- Mock visual mode 138 | mock_vim.fn.mode = function() 139 | return "V" 140 | end 141 | mock_vim.api.nvim_get_mode = function() 142 | return { mode = "V" } 143 | end 144 | 145 | -- Mock visual_commands module 146 | package.loaded["claudecode.visual_commands"] = { 147 | get_visual_range = function() 148 | return 2, 4 -- Lines 2 to 4 149 | end, 150 | } 151 | 152 | local line_entries = { 153 | [2] = { type = "file", name = "file1.lua" }, 154 | [3] = { type = "directory", name = "src" }, 155 | [4] = { type = "file", name = "file2.lua" }, 156 | } 157 | 158 | local mock_oil = { 159 | get_current_dir = function(bufnr) 160 | return "/Users/test/project/" 161 | end, 162 | get_entry_on_line = function(bufnr, line) 163 | return line_entries[line] 164 | end, 165 | } 166 | 167 | package.loaded["oil"] = mock_oil 168 | 169 | local files, err = integrations._get_oil_selection() 170 | 171 | expect(err).to_be_nil() 172 | expect(files).to_be_table() 173 | expect(#files).to_be(3) 174 | expect(files[1]).to_be("/Users/test/project/file1.lua") 175 | expect(files[2]).to_be("/Users/test/project/src/") 176 | expect(files[3]).to_be("/Users/test/project/file2.lua") 177 | end) 178 | 179 | it("should handle errors gracefully", function() 180 | local mock_oil = { 181 | get_cursor_entry = function() 182 | error("Failed to get cursor entry") 183 | end, 184 | } 185 | 186 | package.loaded["oil"] = mock_oil 187 | 188 | local files, err = integrations._get_oil_selection() 189 | 190 | expect(err).to_be("Failed to get cursor entry") 191 | expect(files).to_be_table() 192 | expect(#files).to_be(0) 193 | end) 194 | 195 | it("should handle missing oil.nvim", function() 196 | package.loaded["oil"] = nil 197 | 198 | local files, err = integrations._get_oil_selection() 199 | 200 | expect(err).to_be("oil.nvim not available") 201 | expect(files).to_be_table() 202 | expect(#files).to_be(0) 203 | end) 204 | end) 205 | 206 | describe("get_selected_files_from_tree", function() 207 | it("should detect oil filetype and delegate to _get_oil_selection", function() 208 | mock_vim.bo.filetype = "oil" 209 | 210 | local mock_oil = { 211 | get_cursor_entry = function() 212 | return { type = "file", name = "test.lua" } 213 | end, 214 | get_current_dir = function(bufnr) 215 | return "/path/" 216 | end, 217 | } 218 | 219 | package.loaded["oil"] = mock_oil 220 | 221 | local files, err = integrations.get_selected_files_from_tree() 222 | 223 | expect(err).to_be_nil() 224 | expect(files).to_be_table() 225 | expect(#files).to_be(1) 226 | expect(files[1]).to_be("/path/test.lua") 227 | end) 228 | end) 229 | end) 230 | --------------------------------------------------------------------------------