diff --git a/README.md b/README.md index 4c6a69df..8a96fdef 100644 --- a/README.md +++ b/README.md @@ -911,6 +911,7 @@ opts = { ## Troubleshooting +- **First stop:** Run `:checkhealth claudecode` — it verifies the Claude CLI is installed, the WebSocket server is running, the lock file exists, and whether Claude is connected - **Claude not connecting?** Check `:ClaudeCodeStatus` and verify lock file exists in `~/.claude/ide/` (or `$CLAUDE_CONFIG_DIR/ide/` if `CLAUDE_CONFIG_DIR` is set) - **Need debug logs?** Set `log_level = "debug"` in opts - **Terminal issues?** Try `provider = "native"` if using snacks.nvim diff --git a/lua/claudecode/health.lua b/lua/claudecode/health.lua new file mode 100644 index 00000000..ac07e341 --- /dev/null +++ b/lua/claudecode/health.lua @@ -0,0 +1,148 @@ +--- Health check for claudecode.nvim, run via :checkhealth claudecode +--- Verifies prerequisites (Neovim version, Claude CLI, terminal provider) +--- and reports live integration state (WebSocket server, lock file, +--- Claude connection) without launching anything. +---@module 'claudecode.health' +local M = {} + +-- vim.health gained start/ok/warn/error in Neovim 0.10; older versions use report_* variants. +local health = vim.health or require("health") +local start = health.start or health.report_start +local ok = health.ok or health.report_ok +local warn = health.warn or health.report_warn +local error_ = health.error or health.report_error +local info = health.info or health.report_info or ok + +---Extracts the executable (first token) from a command string. +---@param cmd string +---@return string executable +local function executable_of(cmd) + assert(type(cmd) == "string" and cmd ~= "", "cmd must be a non-empty string") + return cmd:match("^(%S+)") or cmd +end + +local function check_neovim() + if vim.fn.has("nvim-0.8.0") == 1 then + ok("Neovim >= 0.8.0") + else + error_("Neovim >= 0.8.0 is required") + end +end + +---@param claudecode table The main plugin module +local function check_setup(claudecode) + if claudecode.state.initialized then + ok("claudecode.nvim " .. claudecode.version:string() .. " is set up") + return true + end + error_("setup() has not been called", { 'Call require("claudecode").setup() (or use your plugin manager\'s opts)' }) + return false +end + +---@param config table The merged plugin config +local function check_cli(config) + local terminal_cmd = config.terminal_cmd + local cmd = (terminal_cmd and terminal_cmd ~= "") and terminal_cmd or "claude" + local exe = executable_of(cmd) + + if vim.fn.executable(exe) ~= 1 then + error_(("Claude CLI not found: '%s' is not executable"):format(exe), { + "Install Claude Code: https://docs.anthropic.com/en/docs/claude-code", + "Or set `terminal_cmd` in setup() to the full path of the CLI", + }) + return + end + + ok(("Claude CLI found: %s (%s)"):format(exe, vim.fn.exepath(exe))) + + local version_ok, output = pcall(vim.fn.system, { exe, "--version" }) + if version_ok and vim.v.shell_error == 0 then + info("CLI version: " .. vim.trim(output)) + else + warn(("'%s --version' failed; the configured command may not be the Claude CLI"):format(exe)) + end +end + +---@param config table The merged plugin config +local function check_terminal_provider(config) + local provider = config.terminal and config.terminal.provider or "auto" + if type(provider) == "table" then + info("Terminal provider: custom (table)") + return + end + + if provider == "auto" or provider == "snacks" then + local has_snacks = pcall(require, "snacks") + if has_snacks then + ok(("Terminal provider '%s': snacks.nvim available"):format(provider)) + elseif provider == "snacks" then + error_("Terminal provider 'snacks' configured but snacks.nvim is not installed") + else + ok("Terminal provider 'auto': snacks.nvim not installed, will fall back to native terminal") + end + elseif provider == "external" then + local cmd = config.terminal.provider_opts and config.terminal.provider_opts.external_terminal_cmd + if cmd and (type(cmd) == "function" or cmd:find("%%s")) then + ok("Terminal provider 'external' configured") + else + error_("Terminal provider 'external' requires provider_opts.external_terminal_cmd containing '%s'") + end + else + ok(("Terminal provider: %s"):format(provider)) + end +end + +---@param claudecode table The main plugin module +local function check_server(claudecode) + local server = require("claudecode.server.init") + local status = server.get_status() + + if not status.running then + warn("WebSocket server is not running", { + "The server starts automatically when auto_start = true (default)", + "Or start it manually with :ClaudeCodeStart", + }) + return + end + + ok(("WebSocket server running on port %d"):format(status.port)) + + local lockfile = require("claudecode.lockfile") + local lock_path = lockfile.lock_dir .. "/" .. tostring(status.port) .. ".lock" + if vim.fn.filereadable(lock_path) == 1 then + ok("Lock file present: " .. lock_path) + else + error_("Lock file missing: " .. lock_path, { + "Claude discovers this Neovim instance through the lock file", + "Restart the integration with :ClaudeCodeStop and :ClaudeCodeStart", + }) + end + + if claudecode.is_claude_connected() then + ok(("Claude Code is connected (%d client(s))"):format(status.client_count)) + else + info("No Claude Code client connected yet (launch one with :ClaudeCode)") + end +end + +function M.check() + start("claudecode.nvim") + + check_neovim() + + local loaded, claudecode = pcall(require, "claudecode") + if not loaded then + error_("Could not load claudecode module: " .. tostring(claudecode)) + return + end + + if not check_setup(claudecode) then + return + end + + check_cli(claudecode.state.config) + check_terminal_provider(claudecode.state.config) + check_server(claudecode) +end + +return M diff --git a/tests/unit/health_spec.lua b/tests/unit/health_spec.lua new file mode 100644 index 00000000..54c15f24 --- /dev/null +++ b/tests/unit/health_spec.lua @@ -0,0 +1,165 @@ +-- luacheck: globals expect +require("tests.busted_setup") + +describe("health", function() + local health + local reports + + -- State the stubs expose to the module under test + local fake_state + local server_status + local executables + local lock_readable + local claude_connected + + local function record(level) + return function(msg) + table.insert(reports, { level = level, msg = msg }) + end + end + + local function has_report(level, pattern) + for _, r in ipairs(reports) do + if r.level == level and r.msg:find(pattern) then + return true + end + end + return false + end + + before_each(function() + reports = {} + executables = { claude = true } + lock_readable = true + claude_connected = true + server_status = { running = true, port = 12345, client_count = 1 } + fake_state = { + initialized = true, + config = { terminal_cmd = nil, terminal = { provider = "native" } }, + } + + vim.health = { + start = record("start"), + ok = record("ok"), + warn = record("warn"), + error = record("error"), + info = record("info"), + } + vim.trim = vim.trim or function(s) + return s:match("^%s*(.-)%s*$") + end + vim.v = vim.v or {} + vim.v.shell_error = 0 + vim.fn.executable = function(exe) + return executables[exe] and 1 or 0 + end + vim.fn.exepath = function(exe) + return "/usr/bin/" .. exe + end + vim.fn.system = function(_) + return "1.0.0 (Claude Code)\n" + end + vim.fn.filereadable = function(_) + return lock_readable and 1 or 0 + end + + package.loaded["claudecode"] = { + version = { + string = function() + return "0.2.0" + end, + }, + state = fake_state, + is_claude_connected = function() + return claude_connected + end, + } + package.loaded["claudecode.server.init"] = { + get_status = function() + return server_status + end, + } + package.loaded["claudecode.lockfile"] = { lock_dir = "/tmp/claude/ide" } + + package.loaded["claudecode.health"] = nil + health = require("claudecode.health") + end) + + after_each(function() + package.loaded["claudecode"] = nil + package.loaded["claudecode.server.init"] = nil + package.loaded["claudecode.lockfile"] = nil + package.loaded["claudecode.health"] = nil + end) + + it("reports all-ok for a healthy setup", function() + health.check() + + expect(has_report("ok", "Neovim")).to_be_true() + expect(has_report("ok", "is set up")).to_be_true() + expect(has_report("ok", "Claude CLI found")).to_be_true() + expect(has_report("ok", "WebSocket server running on port 12345")).to_be_true() + expect(has_report("ok", "Lock file present")).to_be_true() + expect(has_report("ok", "Claude Code is connected")).to_be_true() + expect(has_report("error", ".")).to_be_false() + end) + + it("errors when setup() was not called and stops early", function() + fake_state.initialized = false + + health.check() + + expect(has_report("error", "setup%(%) has not been called")).to_be_true() + expect(has_report("ok", "Claude CLI found")).to_be_false() + end) + + it("errors when the Claude CLI is missing", function() + executables = {} + + health.check() + + expect(has_report("error", "Claude CLI not found")).to_be_true() + end) + + it("resolves the executable from a custom terminal_cmd", function() + fake_state.config.terminal_cmd = "/opt/claude/bin/claude --flag" + executables["/opt/claude/bin/claude"] = true + + health.check() + + expect(has_report("ok", "Claude CLI found: /opt/claude/bin/claude")).to_be_true() + end) + + it("warns when the server is not running", function() + server_status = { running = false, port = nil, client_count = 0 } + + health.check() + + expect(has_report("warn", "WebSocket server is not running")).to_be_true() + expect(has_report("ok", "Lock file present")).to_be_false() + end) + + it("errors when the lock file is missing", function() + lock_readable = false + + health.check() + + expect(has_report("error", "Lock file missing")).to_be_true() + end) + + it("reports info when no client is connected", function() + claude_connected = false + + health.check() + + expect(has_report("info", "No Claude Code client connected")).to_be_true() + end) + + it("errors when snacks provider is configured but unavailable", function() + fake_state.config.terminal = { provider = "snacks" } + + health.check() + + expect(has_report("error", "snacks.nvim is not installed")).to_be_true() + end) +end)