Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions lua/claudecode/health.lua
Original file line number Diff line number Diff line change
@@ -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
165 changes: 165 additions & 0 deletions tests/unit/health_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
Loading