Skip to content

Commit d42fe45

Browse files
authored
feat: add :checkhealth claudecode health check (#275)
1 parent 80752b2 commit d42fe45

3 files changed

Lines changed: 314 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,7 @@ opts = {
911911

912912
## Troubleshooting
913913

914+
- **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
914915
- **Claude not connecting?** Check `:ClaudeCodeStatus` and verify lock file exists in `~/.claude/ide/` (or `$CLAUDE_CONFIG_DIR/ide/` if `CLAUDE_CONFIG_DIR` is set)
915916
- **Need debug logs?** Set `log_level = "debug"` in opts
916917
- **Terminal issues?** Try `provider = "native"` if using snacks.nvim

lua/claudecode/health.lua

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
--- Health check for claudecode.nvim, run via :checkhealth claudecode
2+
--- Verifies prerequisites (Neovim version, Claude CLI, terminal provider)
3+
--- and reports live integration state (WebSocket server, lock file,
4+
--- Claude connection) without launching anything.
5+
---@module 'claudecode.health'
6+
local M = {}
7+
8+
-- vim.health gained start/ok/warn/error in Neovim 0.10; older versions use report_* variants.
9+
local health = vim.health or require("health")
10+
local start = health.start or health.report_start
11+
local ok = health.ok or health.report_ok
12+
local warn = health.warn or health.report_warn
13+
local error_ = health.error or health.report_error
14+
local info = health.info or health.report_info or ok
15+
16+
---Extracts the executable (first token) from a command string.
17+
---@param cmd string
18+
---@return string executable
19+
local function executable_of(cmd)
20+
assert(type(cmd) == "string" and cmd ~= "", "cmd must be a non-empty string")
21+
return cmd:match("^(%S+)") or cmd
22+
end
23+
24+
local function check_neovim()
25+
if vim.fn.has("nvim-0.8.0") == 1 then
26+
ok("Neovim >= 0.8.0")
27+
else
28+
error_("Neovim >= 0.8.0 is required")
29+
end
30+
end
31+
32+
---@param claudecode table The main plugin module
33+
local function check_setup(claudecode)
34+
if claudecode.state.initialized then
35+
ok("claudecode.nvim " .. claudecode.version:string() .. " is set up")
36+
return true
37+
end
38+
error_("setup() has not been called", { 'Call require("claudecode").setup() (or use your plugin manager\'s opts)' })
39+
return false
40+
end
41+
42+
---@param config table The merged plugin config
43+
local function check_cli(config)
44+
local terminal_cmd = config.terminal_cmd
45+
local cmd = (terminal_cmd and terminal_cmd ~= "") and terminal_cmd or "claude"
46+
local exe = executable_of(cmd)
47+
48+
if vim.fn.executable(exe) ~= 1 then
49+
error_(("Claude CLI not found: '%s' is not executable"):format(exe), {
50+
"Install Claude Code: https://docs.anthropic.com/en/docs/claude-code",
51+
"Or set `terminal_cmd` in setup() to the full path of the CLI",
52+
})
53+
return
54+
end
55+
56+
ok(("Claude CLI found: %s (%s)"):format(exe, vim.fn.exepath(exe)))
57+
58+
local version_ok, output = pcall(vim.fn.system, { exe, "--version" })
59+
if version_ok and vim.v.shell_error == 0 then
60+
info("CLI version: " .. vim.trim(output))
61+
else
62+
warn(("'%s --version' failed; the configured command may not be the Claude CLI"):format(exe))
63+
end
64+
end
65+
66+
---@param config table The merged plugin config
67+
local function check_terminal_provider(config)
68+
local provider = config.terminal and config.terminal.provider or "auto"
69+
if type(provider) == "table" then
70+
info("Terminal provider: custom (table)")
71+
return
72+
end
73+
74+
if provider == "auto" or provider == "snacks" then
75+
local has_snacks = pcall(require, "snacks")
76+
if has_snacks then
77+
ok(("Terminal provider '%s': snacks.nvim available"):format(provider))
78+
elseif provider == "snacks" then
79+
error_("Terminal provider 'snacks' configured but snacks.nvim is not installed")
80+
else
81+
ok("Terminal provider 'auto': snacks.nvim not installed, will fall back to native terminal")
82+
end
83+
elseif provider == "external" then
84+
local cmd = config.terminal.provider_opts and config.terminal.provider_opts.external_terminal_cmd
85+
if cmd and (type(cmd) == "function" or cmd:find("%%s")) then
86+
ok("Terminal provider 'external' configured")
87+
else
88+
error_("Terminal provider 'external' requires provider_opts.external_terminal_cmd containing '%s'")
89+
end
90+
else
91+
ok(("Terminal provider: %s"):format(provider))
92+
end
93+
end
94+
95+
---@param claudecode table The main plugin module
96+
local function check_server(claudecode)
97+
local server = require("claudecode.server.init")
98+
local status = server.get_status()
99+
100+
if not status.running then
101+
warn("WebSocket server is not running", {
102+
"The server starts automatically when auto_start = true (default)",
103+
"Or start it manually with :ClaudeCodeStart",
104+
})
105+
return
106+
end
107+
108+
ok(("WebSocket server running on port %d"):format(status.port))
109+
110+
local lockfile = require("claudecode.lockfile")
111+
local lock_path = lockfile.lock_dir .. "/" .. tostring(status.port) .. ".lock"
112+
if vim.fn.filereadable(lock_path) == 1 then
113+
ok("Lock file present: " .. lock_path)
114+
else
115+
error_("Lock file missing: " .. lock_path, {
116+
"Claude discovers this Neovim instance through the lock file",
117+
"Restart the integration with :ClaudeCodeStop and :ClaudeCodeStart",
118+
})
119+
end
120+
121+
if claudecode.is_claude_connected() then
122+
ok(("Claude Code is connected (%d client(s))"):format(status.client_count))
123+
else
124+
info("No Claude Code client connected yet (launch one with :ClaudeCode)")
125+
end
126+
end
127+
128+
function M.check()
129+
start("claudecode.nvim")
130+
131+
check_neovim()
132+
133+
local loaded, claudecode = pcall(require, "claudecode")
134+
if not loaded then
135+
error_("Could not load claudecode module: " .. tostring(claudecode))
136+
return
137+
end
138+
139+
if not check_setup(claudecode) then
140+
return
141+
end
142+
143+
check_cli(claudecode.state.config)
144+
check_terminal_provider(claudecode.state.config)
145+
check_server(claudecode)
146+
end
147+
148+
return M

tests/unit/health_spec.lua

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
-- luacheck: globals expect
2+
require("tests.busted_setup")
3+
4+
describe("health", function()
5+
local health
6+
local reports
7+
8+
-- State the stubs expose to the module under test
9+
local fake_state
10+
local server_status
11+
local executables
12+
local lock_readable
13+
local claude_connected
14+
15+
local function record(level)
16+
return function(msg)
17+
table.insert(reports, { level = level, msg = msg })
18+
end
19+
end
20+
21+
local function has_report(level, pattern)
22+
for _, r in ipairs(reports) do
23+
if r.level == level and r.msg:find(pattern) then
24+
return true
25+
end
26+
end
27+
return false
28+
end
29+
30+
before_each(function()
31+
reports = {}
32+
executables = { claude = true }
33+
lock_readable = true
34+
claude_connected = true
35+
server_status = { running = true, port = 12345, client_count = 1 }
36+
fake_state = {
37+
initialized = true,
38+
config = { terminal_cmd = nil, terminal = { provider = "native" } },
39+
}
40+
41+
vim.health = {
42+
start = record("start"),
43+
ok = record("ok"),
44+
warn = record("warn"),
45+
error = record("error"),
46+
info = record("info"),
47+
}
48+
vim.trim = vim.trim or function(s)
49+
return s:match("^%s*(.-)%s*$")
50+
end
51+
vim.v = vim.v or {}
52+
vim.v.shell_error = 0
53+
vim.fn.executable = function(exe)
54+
return executables[exe] and 1 or 0
55+
end
56+
vim.fn.exepath = function(exe)
57+
return "/usr/bin/" .. exe
58+
end
59+
vim.fn.system = function(_)
60+
return "1.0.0 (Claude Code)\n"
61+
end
62+
vim.fn.filereadable = function(_)
63+
return lock_readable and 1 or 0
64+
end
65+
66+
package.loaded["claudecode"] = {
67+
version = {
68+
string = function()
69+
return "0.2.0"
70+
end,
71+
},
72+
state = fake_state,
73+
is_claude_connected = function()
74+
return claude_connected
75+
end,
76+
}
77+
package.loaded["claudecode.server.init"] = {
78+
get_status = function()
79+
return server_status
80+
end,
81+
}
82+
package.loaded["claudecode.lockfile"] = { lock_dir = "/tmp/claude/ide" }
83+
84+
package.loaded["claudecode.health"] = nil
85+
health = require("claudecode.health")
86+
end)
87+
88+
after_each(function()
89+
package.loaded["claudecode"] = nil
90+
package.loaded["claudecode.server.init"] = nil
91+
package.loaded["claudecode.lockfile"] = nil
92+
package.loaded["claudecode.health"] = nil
93+
end)
94+
95+
it("reports all-ok for a healthy setup", function()
96+
health.check()
97+
98+
expect(has_report("ok", "Neovim")).to_be_true()
99+
expect(has_report("ok", "is set up")).to_be_true()
100+
expect(has_report("ok", "Claude CLI found")).to_be_true()
101+
expect(has_report("ok", "WebSocket server running on port 12345")).to_be_true()
102+
expect(has_report("ok", "Lock file present")).to_be_true()
103+
expect(has_report("ok", "Claude Code is connected")).to_be_true()
104+
expect(has_report("error", ".")).to_be_false()
105+
end)
106+
107+
it("errors when setup() was not called and stops early", function()
108+
fake_state.initialized = false
109+
110+
health.check()
111+
112+
expect(has_report("error", "setup%(%) has not been called")).to_be_true()
113+
expect(has_report("ok", "Claude CLI found")).to_be_false()
114+
end)
115+
116+
it("errors when the Claude CLI is missing", function()
117+
executables = {}
118+
119+
health.check()
120+
121+
expect(has_report("error", "Claude CLI not found")).to_be_true()
122+
end)
123+
124+
it("resolves the executable from a custom terminal_cmd", function()
125+
fake_state.config.terminal_cmd = "/opt/claude/bin/claude --flag"
126+
executables["/opt/claude/bin/claude"] = true
127+
128+
health.check()
129+
130+
expect(has_report("ok", "Claude CLI found: /opt/claude/bin/claude")).to_be_true()
131+
end)
132+
133+
it("warns when the server is not running", function()
134+
server_status = { running = false, port = nil, client_count = 0 }
135+
136+
health.check()
137+
138+
expect(has_report("warn", "WebSocket server is not running")).to_be_true()
139+
expect(has_report("ok", "Lock file present")).to_be_false()
140+
end)
141+
142+
it("errors when the lock file is missing", function()
143+
lock_readable = false
144+
145+
health.check()
146+
147+
expect(has_report("error", "Lock file missing")).to_be_true()
148+
end)
149+
150+
it("reports info when no client is connected", function()
151+
claude_connected = false
152+
153+
health.check()
154+
155+
expect(has_report("info", "No Claude Code client connected")).to_be_true()
156+
end)
157+
158+
it("errors when snacks provider is configured but unavailable", function()
159+
fake_state.config.terminal = { provider = "snacks" }
160+
161+
health.check()
162+
163+
expect(has_report("error", "snacks.nvim is not installed")).to_be_true()
164+
end)
165+
end)

0 commit comments

Comments
 (0)