Checks the session folder's languages against your LSP plugins, plugin folders and MCP language servers, and shows what is missing

A Claude Code mod that checks whether the code in the session's folder has a language server Claude can use, and shows what's missing.
With a language server, Claude answers "where is this defined" or "what calls this" with one goToDefinition or findReferences call that returns file paths and line numbers. Without one it greps the repository and reads files until it finds the same thing, and everything it reads stays in the context for the rest of the session. Claude Code gets its language servers from plugins, so a project can easily hold a language that no installed plugin covers, or a plugin whose server program was never installed. Claude Code doesn't tell you when that happens. This mod does.
A short item sits in the band above the prompt: LSP ready in green when every language in the folder has a server, or a count like LSP 2/3. The count is red when a plugin is on but its server program isn't on PATH, and yellow when a plugin is missing or switched off. The same count goes to the status line. The item's Open button opens the LSP pane. /lsp-status opens it too and prints the report as text, which the model also reads.
The pane has one card per language in the folder, each in one of these states:
Cards with a problem come first and carry the line that fixes it, such as claude plugin install rust-analyzer-lsp@claude-plugins-official or npm install --global pyright. Ask Claude to fix puts those lines in the prompt box for you to send or edit. Ready cards have a Test button that runs the LSP tool's documentSymbol on a file of that language, the same call the model makes, and shows the answer or the error. Two lines at the bottom name the languages nothing covers and the file types the mod doesn't recognize.
Some servers need a project file before they have anything to load. A C# folder with no .sln, .slnx or .csproj under it gets a warning, because csharp-ls only searches down from the folder the session starts in. Go and Rust get one when no go.mod or Cargo.toml is in the folder or any folder above it.
The file list comes from git ls-files --cached --others --exclude-standard, so ignored build output doesn't count. Outside a repository the mod walks the folder instead, skipping dot folders, node_modules, bin, obj, dist, build and similar, and stops after 3,000 folders.
Language servers come from every place Claude Code loads one, plus MCP servers:
plugin.json or .lsp.jsonCLAUDE_CODE_PLUGIN_DIRS~/.claude/skills and the project's .claude/skillslsp_status tool that answers { "servers": [{ "id": "...", "extensions": [".ext"] }] }For the last one the mod asks every connected server that has the tool, plus any server in your MCP config with "lsp" in its name. It calls lsp_status and no other tool. The call goes through Claude Code's permission check like any other tool call. In auto mode that check sometimes doesn't rule on it, and then the call is refused. The mod saves each server's last good answer and uses it when a call is refused or times out, and it tries again 20 seconds later, up to three times. A line at the bottom of the pane says what each MCP server answered. To keep the call away from the check entirely, add the tool to permissions.allow in ~/.claude/settings.json, such as "mcp__my_lsp_server__lsp_status".
A file type counts as a language when the mod's own table names it or when any of those servers declares it. Data formats such as JSON and YAML show only once some server covers them. Any other type that isn't on the mod's list of non-code types (images, archives, lock files, engine assets and so on) goes in the unknown list.
A plugin counts as installed when Claude Code's install record has a user install, or a project or local install for this folder or one above it. It counts as on when the merged settings set it to true under enabledPlugins. A plugin under a skills folder counts as on unless enabledPlugins sets it to false. Server commands are looked up with where on Windows and which elsewhere.
The mod scans when the session starts, when you press Rescan or run /lsp-status, and after a turn once the last scan is a minute old or the session has moved to another folder.
The pane reads plugin files and settings, not the running session. A plugin installed or enabled mid-session shows as ready before the LSP tool has loaded its server, so restart the session after a fix and press Test.
MCP-only coverage is read-only, and the model reaches it through that MCP server's own tools, not the LSP tool. Until one of a server's lsp_status calls gets through, the languages only it covers show as uncovered.
The band shows one drawing. Another mod that draws its own band line without passing on what next(e) returns hides this item whenever it runs first. The fix in that mod is to call next(e) and put the result at the end of its own line.
The function hooks API is early access and changes between releases. The mod was built against Claude Code 2.1.299 and has been used in the desktop app on Windows 11. macOS and Linux are untested.
At the prompt of a terminal session:
/plugin install lsp-status --marketplace shin2344234/claude-lsp-status
Answer y to add the marketplace, then pick a scope. From a shell:
claude plugin install lsp-status --marketplace shin2344234/claude-lsp-status
In the desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins, Add marketplace with shin2344234/claude-lsp-status, then Add plugin.
hooks/scan.ts holds the language table, the server matching, the row states and the text the command and the fix button produce. hooks/register.tsx reads the files, plugins, settings, MCP servers and PATH, and draws the band item and the pane. types/index.d.ts declares the values the mod keeps in session state.
To run a clone, name its folder in CLAUDE_CODE_PLUGIN_DIRS, either in your shell or in the env block of ~/.claude/settings.json, which the desktop app also reads. Add CLAUDE_CODE_PLUGIN_DIR_WATCH=1 there to reload the mod when you save a file.
claude plugin validate .
claude plugin test .
scan.test.ts covers the matching, each state, MCP-only coverage, file types that only a server declares, and the unknown list. pane.test.tsx runs the command against a made-up Windows machine and draws the pane and the band on the terminal and desktop surfaces. It covers an MCP server with lsp_status, a refused call with and without a saved answer, plugins from CLAUDE_CODE_PLUGIN_DIRS and the skills folder, Test against a server that answers and one that fails, and the band item on its own, after another mod's line, and under a survey.
MIT
hooks/register.tsx 477 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, FsEntry, Register } from 'claude-code'
3
4import type { McpCheck, Probe, Report, Row } from '../types'
5import { LABEL, LANGS, baseName, coverage, firstPath, fits, fixRequest, headline, mcpServersOf, pick, rows, serversOf, summary, tally, unplaced } from './scan'
6import type { Server } from './scan'
7
8const PANE = 'lsp-status'
9const OFFICIAL = 'claude-plugins-official'
10const MIN = 60_000
11
12const report = atom({ plugin: 'lsp-status', key: 'report' } as const, null)
13const scanning = atom({ plugin: 'lsp-status', key: 'isScanning' } as const, false)
14const probes = atom({ plugin: 'lsp-status', key: 'probes' } as const, {})
15
16// Folders a walk skips: build output, dependencies, caches. Dot folders are skipped too.
17const SKIP = new Set(['node_modules', 'bin', 'obj', 'dist', 'build', 'out', 'target', 'vendor', 'packages', 'venv', 'env', '__pycache__', 'library', 'temp', 'pods', 'coverage'])
18const MAX_DIRS = 3000
19const MAX_DEPTH = 10
20
21type Install = { scope: string; projectPath?: string; installPath: string }
22type Entry = { name: string; lspServers?: unknown }
23
24async function json<T>($: EngineInterface, path: string): Promise<T | undefined> {
25 try {
26 return JSON.parse(await $.fs.read(path)) as T
27 } catch {
28 return undefined
29 }
30}
31
32const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
33
34/** Tracked and unignored files from git, or a capped walk of the folder outside a repository. */
35async function listFiles($: EngineInterface, cwd: string, join: (...p: string[]) => string) {
36 try {
37 const r = await $.process.run(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { timeoutMs: 20_000 })
38 if (r.exitCode === 0) return { files: r.stdout.split('\0').filter(Boolean), via: 'git' as const, isCapped: r.isStdoutTruncated }
39 } catch {}
40
41 const files: string[] = []
42 const queue: [string, number][] = [['', 0]]
43 let dirs = 0
44 while (queue.length && dirs < MAX_DIRS) {
45 const [rel, depth] = queue.shift()!
46 dirs++
47 let entries: FsEntry[] = []
48 try {
49 entries = await $.fs.list(rel ? join(cwd, rel) : cwd)
50 } catch {
51 continue
52 }
53 for (const e of entries) {
54 const path = rel ? `${rel}/${e.name}` : e.name
55 if (e.kind === 'file') files.push(path)
56 else if (e.kind === 'dir' && depth < MAX_DEPTH && !e.name.startsWith('.') && !SKIP.has(e.name.toLowerCase()))
57 queue.push([path, depth + 1])
58 }
59 }
60 return { files, via: 'walk' as const, isCapped: queue.length > 0 }
61}
62
63/** The servers a plugin's own manifest or `.lsp.json` declares, for a plugin folder on disk. */
64async function serversIn($: EngineInterface, dir: string, join: (...p: string[]) => string) {
65 const own = (await json<{ lspServers?: unknown }>($, join(dir, '.claude-plugin', 'plugin.json')))?.lspServers
66 const declared = serversOf(typeof own === 'string' ? await json($, join(dir, own)) : own)
67 return declared.length ? declared : serversOf(await json($, join(dir, '.lsp.json')))
68}
69
70/**
71 * Language servers that MCP servers report. Every server with an `lsp_status` tool is asked. One
72 * still connecting, or left out of the tool list, is found by "lsp" in its name in the user's MCP
73 * config, this project's, or `.mcp.json`.
74 */
75async function mcpServers($: EngineInterface, globalConfig: string, cwd: string, join: (...p: string[]) => string) {
76 const names = new Map<string, string>()
77 const note = (name: string) => names.set(name.replace(/[^a-z0-9]/gi, '_').toLowerCase(), name)
78 const tools = await $.tool.list().catch(() => [])
79 for (const t of tools) {
80 const m = /^mcp__(.+)__lsp_status$/.exec(t.name)
81 if (m) note(m[1]!)
82 }
83 type McpConfig = { mcpServers?: Record<string, unknown> }
84 const global = await json<McpConfig & { projects?: Record<string, McpConfig> }>($, globalConfig)
85 const project = Object.entries(global?.projects ?? {}).find(([p]) => norm(p) === norm(cwd))?.[1]
86 const local = await json<McpConfig>($, join(cwd, '.mcp.json'))
87 for (const c of [global, project, local]) for (const name of Object.keys(c?.mcpServers ?? {})) if (/lsp/i.test(name)) note(name)
88
89 const servers: Server[] = []
90 const checks: McpCheck[] = []
91 for (const name of names.values()) {
92 let problem: string | undefined
93 // A server that never answers costs ten seconds, not the scan. A sleep that fails waits forever, so the call decides.
94 const answer = await Promise.race([
95 $.mcp.call(name, 'lsp_status').catch((err: unknown) => {
96 problem = (err instanceof Error ? err.message : String(err)).split('\n')[0]!.slice(0, 160)
97 return undefined
98 }),
99 $.clock.sleep(10_000).then(
100 () => ((problem ??= 'no answer within 10 seconds'), undefined),
101 () => new Promise<undefined>(() => {}),
102 ),
103 ])
104 const text = (answer?.content.find(c => c.type === 'text') as { text?: string } | undefined)?.text
105 let parsed: unknown
106 if (answer?.isError) problem = text?.split('\n')[0]?.slice(0, 160) || 'its lsp_status tool reported an error'
107 else if (answer) {
108 try {
109 parsed = answer.structuredContent ?? JSON.parse(text ?? 'null')
110 } catch {}
111 if (!mcpServersOf(name, parsed).length) problem = 'its lsp_status answer was not a list of servers'
112 }
113
114 // A server's language list rarely changes, so its last good answer stands in when a call is
115 // refused or times out. In auto mode the safety check can refuse a call it doesn't rule on.
116 const key = `mcp:${name}`
117 if (!problem) await $.store.set(key, { at: await $.clock.now(), answer: parsed }).catch(() => {})
118 const saved = problem ? ((await $.store.get(key).catch(() => undefined)) as { at?: number; answer?: unknown } | undefined) : undefined
119 const found = mcpServersOf(name, problem ? saved?.answer : parsed)
120 servers.push(...found)
121 checks.push({
122 server: name,
123 servers: found.length,
124 ...(problem && { problem }),
125 ...(problem && found.length && saved?.at !== undefined && { savedAt: saved.at }),
126 })
127 }
128 return { servers, checks }
129}
130
131/**
132 * Every language server you can reach: the plugins in your marketplaces and their installs, the
133 * plugins loaded from CLAUDE_CODE_PLUGIN_DIRS and the skills folders, and the language servers an
134 * MCP server reports. A marketplace entry usually lists its plugin's servers; an installed plugin
135 * whose entry doesn't may list them in its own manifest or `.lsp.json`.
136 */
137async function gatherServers($: EngineInterface, cwd: string, join: (...p: string[]) => string, isWindows: boolean) {
138 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '~'
139 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
140 const config = custom ?? join(home, '.claude')
141 const plugins = join(config, 'plugins')
142 const markets = (await json<Record<string, { installLocation?: string }>>($, join(plugins, 'known_marketplaces.json'))) ?? {}
143 const installed = (await json<{ plugins?: Record<string, Install[]> }>($, join(plugins, 'installed_plugins.json')))?.plugins ?? {}
144 const settings = await $.settings.read()
145 const enabled = (settings.enabledPlugins ?? {}) as Record<string, unknown>
146 const here = norm(cwd)
147
148 // A user install counts everywhere; a project or local one only in its folder and below.
149 const installFor = (id: string) =>
150 installed[id]?.find(
151 i => i.scope === 'user' || i.scope === 'managed' || (i.projectPath && (here === norm(i.projectPath) || here.startsWith(norm(i.projectPath) + '/'))),
152 )
153
154 const servers: Server[] = []
155 const hinted: Record<string, string[]> = {}
156 const seen = new Set<string>()
157 const hintName = new Map(LANGS.flatMap(l => (l.plugins ?? []).map(p => [p, l.id] as const)))
158
159 const add = async (id: string, block: unknown) => {
160 seen.add(id)
161 const inst = installFor(id)
162 let declared = serversOf(block)
163 if (!declared.length && inst) declared = await serversIn($, inst.installPath, join)
164 for (const d of declared)
165 servers.push({ ...d, plugin: id, isInstalled: Boolean(inst), isEnabled: enabled[id] === true, isOfficial: id.endsWith(`@${OFFICIAL}`), isMcp: false })
166 const lang = hintName.get(id.split('@')[0]!)
167 if (!declared.length && lang && !inst) (hinted[lang] ??= []).push(id)
168 }
169
170 for (const [market, m] of Object.entries(markets)) {
171 if (!m.installLocation) continue
172 const entries = (await json<{ plugins?: Entry[] }>($, join(m.installLocation, '.claude-plugin', 'marketplace.json')))?.plugins ?? []
173 for (const p of entries) {
174 const id = `${p.name}@${market}`
175 if (p.lspServers || installed[id] || hintName.has(p.name)) await add(id, p.lspServers)
176 }
177 }
178 // Installed from a marketplace that is gone, or from a folder.
179 for (const id of Object.keys(installed)) if (!seen.has(id)) await add(id, undefined)
180
181 // A plugin folder named in CLAUDE_CODE_PLUGIN_DIRS always loads. One under a skills folder loads
182 // as <name>@skills-dir unless the settings switch it off.
183 const fromFolder = async (dir: string, kind: string, isEnabled: (id: string) => boolean) => {
184 const name = (await json<{ name?: string }>($, join(dir, '.claude-plugin', 'plugin.json')))?.name
185 if (!name) return
186 const id = `${name}@${kind}`
187 for (const d of await serversIn($, dir, join)) servers.push({ ...d, plugin: id, isInstalled: true, isEnabled: isEnabled(id), isOfficial: false, isMcp: false })
188 }
189 const dirs = (await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? (settings.env as Record<string, string> | undefined)?.CLAUDE_CODE_PLUGIN_DIRS ?? ''
190 for (const dir of dirs.split(isWindows ? ';' : ':').map(d => d.trim()).filter(Boolean))
191 await fromFolder(dir.replace(/^~(?=[\\/]|$)/, home), 'plugin-dir', () => true)
192 for (const root of [join(config, 'skills'), join(cwd, '.claude', 'skills')])
193 for (const e of await $.fs.list(root).catch(() => []))
194 if (e.kind === 'dir') await fromFolder(join(root, e.name), 'skills-dir', id => enabled[id] !== false)
195
196 const mcp = await mcpServers($, custom ? join(config, '.claude.json') : join(home, '.claude.json'), cwd, join)
197 servers.push(...mcp.servers)
198 return { servers, hinted, checks: mcp.checks }
199}
200
201/** Languages whose project file is in neither the folder's files nor, where the server looks up, a folder above. */
202async function missingProjects($: EngineInterface, cwd: string, files: string[], present: string[], join: (...p: string[]) => string) {
203 const missing: string[] = []
204 for (const id of present) {
205 const p = LANGS.find(l => l.id === id)?.project
206 if (!p) continue
207 if (files.some(f => p.files.some(pat => fits(baseName(f), pat)))) continue
208 let found = false
209 if (p.looksUp) {
210 const parts = cwd.split(/[\\/]/)
211 for (let n = parts.length - 1; n > 0 && !found; n--) {
212 // A drive or the POSIX root needs its separator: `C:` alone is the drive's current folder.
213 const dir = join(...parts.slice(0, n))
214 const names = await $.fs.list(dir === '' || dir.endsWith(':') ? dir + '/' : dir).then(es => es.map(e => e.name), () => [])
215 found = names.some(name => p.files.some(pat => fits(name, pat)))
216 }
217 }
218 if (!found) missing.push(id)
219 }
220 return missing
221}
222
223async function gather($: EngineInterface): Promise<Report> {
224 const cwd = await $.session.cwd()
225 const sep = cwd.includes('\\') ? '\\' : '/'
226 const join = (...p: string[]) => p.join(sep)
227 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
228
229 const { files, via, isCapped } = await listFiles($, cwd, join)
230 const { servers, hinted, checks } = await gatherServers($, cwd, join, isWindows)
231 const counts = tally(files, servers).langs
232 const plugins = servers.filter(s => !s.isMcp)
233
234 // Only the commands of the plugin servers that would be picked get looked up.
235 const binaries: Record<string, string | null> = {}
236 for (const t of counts.values()) {
237 const s = pick(t.exts, plugins)
238 if (!s || s.command in binaries) continue
239 const r = await $.process.run([isWindows ? 'where' : 'which', s.command], { timeoutMs: 10_000 }).catch(() => undefined)
240 binaries[s.command] = r?.exitCode === 0 ? (firstPath(r.stdout) ?? null) : null
241 }
242
243 const missingProject = await missingProjects($, cwd, files, [...counts.keys()], join)
244 return {
245 at: await $.clock.now(),
246 cwd,
247 folder: cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd,
248 files: files.length,
249 via,
250 isCapped,
251 rows: rows({ files, servers, hinted, binaries, missingProject, isWindows }),
252 unplaced: unplaced(files, servers),
253 mcp: checks,
254 }
255}
256
257// ponytail: module state, so a reload mid-scan starts a second one; both write the same report.
258let running: Promise<void> | null = null
259// MCP servers often finish connecting after the session starts, so a scan that found one silent
260// tries again 20 seconds later, three times at most.
261let retries = 0
262
263/** Scans the folder, unless a scan is already running, and updates the pane and the status line. */
264function scan($: EngineInterface): Promise<void> {
265 running ??= (async () => {
266 await update($, scanning, () => true)
267 try {
268 const r = await gather($)
269 await update($, report, () => r)
270 const c = coverage(r.rows)
271 $.ui.status(c.need ? `LSP ${c.covered}/${c.need}` : undefined)
272 if (!r.mcp.some(m => m.problem)) retries = 0
273 else if (retries < 3) {
274 retries++
275 $.clock.after(20_000, () => void scan($))
276 }
277 } finally {
278 await update($, scanning, () => false)
279 running = null
280 }
281 })()
282 return running
283}
284
285/** Asks the language's server for the sample file's symbols through the LSP tool, as the model would. */
286async function test($: EngineInterface, row: Row, cwd: string) {
287 const set = (p: Probe) => update($, probes, all => ({ ...all, [row.lang]: p }))
288 await set({ state: 'running', text: `Asking ${row.server} about ${row.sample}` })
289 const filePath = `${cwd}${cwd.includes('\\') ? '\\' : '/'}${row.sample.replace(/\//g, cwd.includes('\\') ? '\\' : '/')}`
290 try {
291 const r = await $.tool.call({ tool: 'LSP', operation: 'documentSymbol', filePath, line: 1, character: 1 })
292 const text = (r.deny ?? r.text ?? '').trim()
293 const first = text.split('\n')[0]?.slice(0, 160) ?? ''
294 if (r.deny || r.isError || /no lsp server|not available|error|failed|timed out/i.test(first)) {
295 await set({ state: 'failed', text: first || 'The LSP tool answered with an error.' })
296 return
297 }
298 const count = (r.result as { resultCount?: number } | undefined)?.resultCount
299 await set({ state: 'ok', text: count !== undefined ? `Answered with ${count} symbols in ${row.sample}` : `Answered: ${first}` })
300 } catch {
301 await set({ state: 'failed', text: 'This session has no LSP tool. Restart it after enabling an LSP plugin.' })
302 }
303}
304
305async function askFix($: EngineInterface, r: Report) {
306 const filled = await $.prompt.fill({ text: fixRequest(r.rows, r.folder) }).catch(() => undefined)
307 if (!filled?.isFilled) $.ui.toast('Could not fill the prompt box. The fixes are listed in the LSP pane.')
308}
309
310/** What the pane says about one MCP server it asked, with the permission rule that stops a refusal. */
311function mcpLine(m: McpCheck): string {
312 const count = `${m.servers} language server${m.servers === 1 ? '' : 's'}`
313 if (!m.problem) return `MCP server ${m.server} reported ${count}.`
314 const rule = /classifier|refused|permission|denied/i.test(m.problem)
315 ? ` To let the call through, add mcp__${m.server.replace(/[^a-zA-Z0-9_-]/g, '_')}__lsp_status to permissions.allow in your settings.`
316 : ''
317 return m.savedAt
318 ? `MCP server ${m.server}: this scan's call failed (${m.problem}), so its last answer stands, ${count}.${rule}`
319 : `MCP server ${m.server} gave no language servers: ${m.problem}.${rule}`
320}
321
322const COLOR: Record<Row['status'], string | undefined> = {
323 ready: 'success',
324 'no-server': 'error',
325 disabled: 'warning',
326 available: 'warning',
327 mcp: 'suggestion',
328 none: undefined,
329}
330
331export const register: Register = on => {
332 on('session.start', async ($, e, next) => {
333 const started = await next(e)
334 await $.command.register({ name: 'lsp-status', description: 'Check LSP coverage for this folder and open the LSP pane' })
335 void scan($)
336 return started
337 })
338
339 on('command.run', { command: 'lsp-status' }, async $ => {
340 await $.ui.open({ id: PANE, title: 'LSP' })
341 await scan($)
342 const r = await read($, report)
343 return { text: r ? summary(r) : 'The scan did not finish.' }
344 })
345
346 // Files, settings and the folder itself change during a session. A minute between scans keeps it cheap.
347 on('turn.complete', async ($, e, next) => {
348 const done = await next(e)
349 if (!e.agentId) {
350 const r = await read($, report)
351 if (!r || r.cwd !== (await $.session.cwd()) || (await $.clock.now()) - r.at > MIN) void scan($)
352 }
353 return done
354 })
355
356 // A short item in the band above the prompt. Whatever a plugin beneath drew stays first: the
357 // item joins the end of its line. A plugin above that draws its own line shows this one only if
358 // it does the same with what its own next(e) returns.
359 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360 const below = await next(e)
361 const c = coverage((await read($, report))?.rows ?? [])
362 if (e.props.hasSurvey || !c.need) return below
363
364 const { Box, Text, Button } = $.ui.resolve(e)
365 const isAll = c.covered === c.need
366 const color = isAll ? 'success' : c.hasMissingServer ? 'error' : 'warning'
367 const item = (
368 <Box key="lsp-status-band" flexDirection="row" gap={1} marginLeft={2} alignItems="center">
369 <Text color={color}>LSP {isAll ? 'ready' : `${c.covered}/${c.need}`}</Text>
370 <Button key="lsp-open" label="Open" plain onPress={() => $.ui.open({ id: PANE, title: 'LSP' })} />
371 </Box>
372 )
373 if (below.type === 'engine') return item
374 if (below.type === 'Box') return { ...below, children: [...(below.children ?? []), item] }
375 return (
376 <Box flexDirection="row" flexWrap="wrap" alignItems="center">
377 {below}
378 {item}
379 </Box>
380 )
381 })
382
383 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
384 const { Box, Text, Button } = $.ui.resolve(e)
385 const r = await read($, report)
386 const isScanning = await read($, scanning)
387 const tested = await read($, probes)
388 if (!r) return <Text dimColor>{isScanning ? 'Reading the folder…' : 'No scan yet. Run /lsp-status.'}</Text>
389
390 const shown = r.rows.filter(x => x.status !== 'none')
391 const none = r.rows.filter(x => x.status === 'none')
392 // A report kept from before the mod listed unknown file types, or asked MCP servers, has none.
393 const others = r.unplaced ?? []
394 const asked = r.mcp ?? []
395 const hasFixes = shown.some(x => x.fix)
396
397 const card = (row: Row) => {
398 const probe = tested[row.lang]
399 return (
400 <Box flexDirection="column" borderStyle="round" borderColor={COLOR[row.status] ?? 'claude'} paddingX={1} marginBottom={1}>
401 <Box flexDirection="row" justifyContent="space-between">
402 <Text weight="semibold">{row.name}</Text>
403 <Text color={COLOR[row.status]}>{LABEL[row.status]}</Text>
404 </Box>
405 <Text dimColor wrap="wrap">
406 {row.files} {row.files === 1 ? 'file' : 'files'}
407 {row.mcp
408 ? ` · ${row.server} through the ${row.mcp} MCP server`
409 : row.server
410 ? ` · ${row.server} from ${row.plugin}`
411 : row.plugin
412 ? ` · ${row.plugin}`
413 : ''}
414 </Text>
415 {row.binary && <Text dimColor wrap="wrap">{row.binary}</Text>}
416 {row.fix && <Text wrap="wrap">Fix: {row.fix}</Text>}
417 {row.notes.map(n => (
418 <Text color={row.status === 'mcp' ? undefined : 'warning'} dimColor={row.status === 'mcp'} wrap="wrap">
419 {n}
420 </Text>
421 ))}
422 {row.status === 'ready' && (
423 <Box flexDirection="row" gap={1} marginTop={1}>
424 <Button
425 key={`test-${row.lang}`}
426 label={probe?.state === 'running' ? 'Testing…' : 'Test'}
427 onPress={() => (probe?.state === 'running' ? undefined : test($, row, r.cwd))}
428 />
429 {probe && (
430 <Text color={probe.state === 'ok' ? 'success' : probe.state === 'failed' ? 'error' : undefined} dimColor={probe.state === 'running'} wrap="wrap">
431 {probe.text}
432 </Text>
433 )}
434 </Box>
435 )}
436 </Box>
437 )
438 }
439
440 return (
441 <Box flexDirection="column">
442 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
443 <Box flexDirection="column">
444 <Text weight="semibold">{r.folder}</Text>
445 <Text dimColor wrap="wrap">
446 {headline(r.rows, r.folder)}
447 {` ${r.files} files read via ${r.via === 'git' ? 'git' : 'a folder walk'}${r.isCapped ? ', stopped early' : ''}.`}
448 </Text>
449 </Box>
450 <Button key="rescan" label={isScanning ? 'Scanning…' : 'Rescan'} onPress={() => scan($)} />
451 </Box>
452 {shown.map(card)}
453 {none.length > 0 && (
454 <Text dimColor wrap="wrap">
455 No plugin or MCP server covers {none.map(n => `${n.name} (${n.files})`).join(', ')}.
456 </Text>
457 )}
458 {others.length > 0 && (
459 <Text dimColor wrap="wrap">
460 Unknown file types with no server: {others.map(([ext, n]) => `${ext} (${n})`).join(', ')}.
461 </Text>
462 )}
463 {asked.map(m => (
464 <Text color={m.problem && !m.savedAt ? 'warning' : undefined} dimColor={!m.problem || Boolean(m.savedAt)} wrap="wrap">
465 {mcpLine(m)}
466 </Text>
467 ))}
468 {hasFixes && (
469 <Box marginTop={1}>
470 <Button key="fix" label="Ask Claude to fix" variant="primary" onPress={() => askFix($, r)} />
471 </Box>
472 )}
473 </Box>
474 )
475 })
476}
477hooks/scan.ts 426 lines1import type { Row, Status } from '../types'
2
3export type Lang = {
4 id: string
5 name: string
6 exts: string[]
7 /** Whole file names that are the language, lowercased, such as `dockerfile`. */
8 names?: string[]
9 /**
10 * A data or markup format. It shows only when some server covers it; with none, its files
11 * are neither a missing language nor an unknown file type.
12 */
13 isData?: true
14 /** Community plugins for the language whose marketplace entry lists no servers, so their extensions can't be read. */
15 plugins?: string[]
16 /**
17 * Project files the server needs to load the code. `looksUp` says a file in a folder above the
18 * session's counts, as it does for gopls; csharp-ls only searches down from the folder it starts in.
19 */
20 project?: { files: string[]; looksUp: boolean; missing: string }
21}
22
23/**
24 * Names and project checks for languages. Which file types count comes from the servers too: an
25 * extension any server declares is a language here even when this table lacks it.
26 */
27export const LANGS: Lang[] = [
28 {
29 id: 'csharp',
30 name: 'C#',
31 exts: ['.cs', '.csx'],
32 plugins: ['csharp-roslyn-lsp', 'csharp-lsp-roslyn'],
33 project: {
34 files: ['*.sln', '*.slnx', '*.csproj'],
35 looksUp: false,
36 missing: 'No .sln or .csproj under this folder, so csharp-ls has no project to load. Start the session in the folder that holds one.',
37 },
38 },
39 { id: 'ts', name: 'TypeScript / JavaScript', exts: ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'] },
40 { id: 'python', name: 'Python', exts: ['.py', '.pyi'], plugins: ['ty-python-lsp'] },
41 {
42 id: 'go',
43 name: 'Go',
44 exts: ['.go'],
45 project: { files: ['go.mod', 'go.work'], looksUp: true, missing: 'No go.mod in this folder or above it, so gopls runs without a module.' },
46 },
47 { id: 'cpp', name: 'C / C++', exts: ['.c', '.h', '.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.hh'] },
48 { id: 'objc', name: 'Objective-C', exts: ['.m', '.mm'] },
49 { id: 'lua', name: 'Lua', exts: ['.lua'] },
50 {
51 id: 'rust',
52 name: 'Rust',
53 exts: ['.rs'],
54 project: { files: ['Cargo.toml'], looksUp: true, missing: 'No Cargo.toml in this folder or above it, so rust-analyzer has no crate to load.' },
55 },
56 { id: 'java', name: 'Java', exts: ['.java'] },
57 { id: 'kotlin', name: 'Kotlin', exts: ['.kt', '.kts'], plugins: ['kotlin-lsp'] },
58 { id: 'groovy', name: 'Groovy', exts: ['.groovy', '.gradle'] },
59 { id: 'scala', name: 'Scala', exts: ['.scala', '.sc'] },
60 { id: 'php', name: 'PHP', exts: ['.php'], plugins: ['php-lsp-claude-code-plugin'] },
61 { id: 'ruby', name: 'Ruby', exts: ['.rb', '.rake', '.gemspec', '.ru', '.erb'] },
62 { id: 'swift', name: 'Swift', exts: ['.swift'], plugins: ['swift-lsp'] },
63 { id: 'dart', name: 'Dart', exts: ['.dart'] },
64 { id: 'gdscript', name: 'GDScript', exts: ['.gd'], plugins: ['godot-lsp-bridge'] },
65 { id: 'vue', name: 'Vue', exts: ['.vue'], plugins: ['vue-lsp'] },
66 { id: 'svelte', name: 'Svelte', exts: ['.svelte'] },
67 { id: 'astro', name: 'Astro', exts: ['.astro'] },
68 { id: 'html', name: 'HTML', exts: ['.html', '.htm'] },
69 { id: 'css', name: 'CSS', exts: ['.css', '.scss', '.sass', '.less'] },
70 { id: 'terraform', name: 'Terraform', exts: ['.tf', '.tfvars'], plugins: ['terraform-lsp'] },
71 { id: 'zig', name: 'Zig', exts: ['.zig'], plugins: ['zig-lsp'] },
72 { id: 'elixir', name: 'Elixir', exts: ['.ex', '.exs'], plugins: ['elixirls-lsp', 'elixir-expert-lsp'] },
73 { id: 'erlang', name: 'Erlang', exts: ['.erl', '.hrl'] },
74 { id: 'gleam', name: 'Gleam', exts: ['.gleam'] },
75 { id: 'julia', name: 'Julia', exts: ['.jl'], plugins: ['julia-lsp'] },
76 { id: 'clojure', name: 'Clojure', exts: ['.clj', '.cljs', '.cljc'], plugins: ['clojure-lsp'] },
77 { id: 'lisp', name: 'Lisp / Scheme', exts: ['.lisp', '.el', '.scm', '.rkt'] },
78 { id: 'solidity', name: 'Solidity', exts: ['.sol'], plugins: ['solidity-lsp'] },
79 { id: 'vb', name: 'VB.NET', exts: ['.vb'], plugins: ['vbnet-lsp-claude-plugin'] },
80 { id: 'fsharp', name: 'F#', exts: ['.fs', '.fsx', '.fsi'] },
81 { id: 'liquid', name: 'Liquid', exts: ['.liquid'], plugins: ['liquid-lsp'] },
82 { id: 'metal', name: 'Metal', exts: ['.metal'], plugins: ['metal-lsp'] },
83 { id: 'haskell', name: 'Haskell', exts: ['.hs'] },
84 { id: 'ocaml', name: 'OCaml', exts: ['.ml', '.mli'] },
85 { id: 'elm', name: 'Elm', exts: ['.elm'] },
86 { id: 'nim', name: 'Nim', exts: ['.nim'] },
87 { id: 'crystal', name: 'Crystal', exts: ['.cr'] },
88 { id: 'haxe', name: 'Haxe', exts: ['.hx'] },
89 { id: 'odin', name: 'Odin', exts: ['.odin'] },
90 { id: 'nix', name: 'Nix', exts: ['.nix'] },
91 { id: 'fortran', name: 'Fortran', exts: ['.f90', '.f95', '.f03', '.f08'] },
92 { id: 'cuda', name: 'CUDA', exts: ['.cu', '.cuh'] },
93 { id: 'perl', name: 'Perl', exts: ['.pl', '.pm'] },
94 { id: 'r', name: 'R', exts: ['.r'] },
95 { id: 'tcl', name: 'Tcl', exts: ['.tcl'] },
96 { id: 'powershell', name: 'PowerShell', exts: ['.ps1', '.psm1', '.psd1'] },
97 { id: 'shell', name: 'Shell', exts: ['.sh', '.bash', '.zsh', '.fish'] },
98 { id: 'batch', name: 'Batch', exts: ['.bat', '.cmd'] },
99 { id: 'shader', name: 'HLSL / GLSL', exts: ['.hlsl', '.hlsli', '.glsl', '.wgsl', '.vert', '.frag'] },
100 { id: 'sql', name: 'SQL', exts: ['.sql'] },
101 { id: 'graphql', name: 'GraphQL', exts: ['.graphql', '.gql'] },
102 { id: 'protobuf', name: 'Protobuf', exts: ['.proto'] },
103 { id: 'qml', name: 'QML', exts: ['.qml'] },
104 { id: 'openscad', name: 'OpenSCAD', exts: ['.scad'] },
105 { id: 'cmake', name: 'CMake', exts: ['.cmake'], names: ['cmakelists.txt'] },
106 { id: 'dockerfile', name: 'Dockerfile', exts: ['.dockerfile'], names: ['dockerfile'] },
107 { id: 'json', name: 'JSON', exts: ['.json', '.jsonc'], isData: true },
108 { id: 'yaml', name: 'YAML', exts: ['.yaml', '.yml'], isData: true },
109 { id: 'toml', name: 'TOML', exts: ['.toml'], isData: true },
110 { id: 'xml', name: 'XML', exts: ['.xml', '.xaml', '.xsd', '.xsl', '.csproj', '.props', '.targets', '.svg'], isData: true },
111 { id: 'markdown', name: 'Markdown', exts: ['.md', '.markdown'], isData: true },
112]
113
114const LANG_BY_ID = new Map(LANGS.map(l => [l.id, l]))
115
116/**
117 * Extensions that are never code: documents, media, archives, build output, lock files and
118 * engine assets. Anything else no server or table entry places is listed as an unknown type.
119 */
120const NOT_CODE = new Set(
121 (
122 '.txt .rst .adoc .pdf .doc .docx .rtf .log .csv .tsv .xls .xlsx .ppt .pptx ' +
123 '.png .jpg .jpeg .gif .bmp .ico .icns .webp .tga .psd .dds .exr .hdr .tif .tiff .avif .heic ' +
124 '.wav .mp3 .ogg .flac .aac .m4a .mp4 .mov .webm .avi .mkv ' +
125 '.ttf .otf .woff .woff2 .eot ' +
126 '.zip .7z .rar .gz .tar .tgz .bz2 .xz .zst .pak .cab .msi .dmg .deb .rpm .apk .aab .ipa .vsix .nupkg ' +
127 '.exe .dll .so .dylib .a .lib .o .obj .pdb .ilk .exp .bin .dat .class .jar .war .wasm .pyc .pyo .node ' +
128 '.lock .lockfile .sum .map .snap .patch .diff .orig .rej .bak .old .tmp .swp .pid .out .cache .db .sqlite .sqlite3 .mdb ' +
129 '.example .sample .template .tmpl .dist .service .socket .timer ' +
130 '.ini .cfg .conf .env .properties .plist .reg .manifest .resx .settings .user .filters .sln .slnx .vcxproj .pbxproj ' +
131 '.editorconfig .gitignore .gitattributes .gitmodules .gitkeep .npmrc .nvmrc .prettierrc .eslintrc .browserslistrc .dockerignore ' +
132 '.pem .crt .cer .key .pfx .p12 .keystore .jks .gpg .asc .sig ' +
133 '.meta .asset .prefab .unity .mat .anim .controller .physicmaterial .shadergraph .import .uid .tscn .tres .godot .res ' +
134 '.fbx .blend .gltf .glb .stl .3mf .ply .usd .usdz .dae .3ds .max .ma .mb .vox ' +
135 '.ds_store .lnk .url .torrent .iso .img .vhd .vmdk .xcf .kra .aseprite .ase .spine .atlas'
136 ).split(' '),
137)
138
139/** A language server a plugin declares or an MCP server reports, and where it stands for this folder. */
140export type Server = {
141 /** `name@marketplace` for a plugin; the MCP server's name for an MCP server. */
142 plugin: string
143 name: string
144 /** The program a plugin's server runs; empty for an MCP server, which runs its own. */
145 command: string
146 /** Extensions as `.ext`, and whole file names such as `dockerfile`, lowercased. */
147 exts: string[]
148 /** The server's own language id for each extension, used for file types the table lacks. */
149 langs: Record<string, string>
150 isInstalled: boolean
151 isEnabled: boolean
152 isOfficial: boolean
153 isMcp: boolean
154}
155
156type Declared = Pick<Server, 'name' | 'command' | 'exts' | 'langs'>
157
158/** The `lspServers` block of a plugin entry or manifest, read into servers. Anything else reads as none. */
159export function serversOf(block: unknown): Declared[] {
160 if (!block || typeof block !== 'object' || Array.isArray(block)) return []
161 return Object.entries(block as Record<string, unknown>).flatMap(([name, v]) => {
162 const s = v as { command?: unknown; extensionToLanguage?: unknown }
163 if (typeof s?.command !== 'string' || !s.extensionToLanguage || typeof s.extensionToLanguage !== 'object') return []
164 const map = Object.entries(s.extensionToLanguage as Record<string, unknown>).map(([e, l]) => [e.toLowerCase(), String(l)] as const)
165 return [{ name, command: s.command, exts: map.map(([e]) => e), langs: Object.fromEntries(map) }]
166 })
167}
168
169/**
170 * The servers in an MCP server's `lsp_status` answer, `{ servers: [{ id, extensions }] }`. An id
171 * such as `local-css` names its language after the first dash. Any other shape reads as none.
172 */
173export function mcpServersOf(server: string, answer: unknown): Server[] {
174 const list = (answer as { servers?: unknown } | null)?.servers
175 if (!Array.isArray(list)) return []
176 return list.flatMap(item => {
177 const { id, extensions } = (item ?? {}) as { id?: unknown; extensions?: unknown }
178 if (typeof id !== 'string' || !Array.isArray(extensions)) return []
179 const exts = extensions.filter((x): x is string => typeof x === 'string').map(x => x.toLowerCase())
180 const lang = id.includes('-') ? id.slice(id.indexOf('-') + 1) : id
181 return [
182 {
183 plugin: server,
184 name: id,
185 command: '',
186 exts,
187 langs: Object.fromEntries(exts.map(e => [e, lang])),
188 isInstalled: true,
189 isEnabled: true,
190 isOfficial: false,
191 isMcp: true,
192 },
193 ]
194 })
195}
196
197export const baseName = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
198
199export function extOf(path: string): string {
200 const name = baseName(path)
201 const dot = name.lastIndexOf('.')
202 return dot > 0 ? name.slice(dot).toLowerCase() : ''
203}
204
205/** Whether a file name fits a pattern such as `*.sln` or `go.mod`, ignoring case. */
206export function fits(name: string, pattern: string): boolean {
207 const n = name.toLowerCase()
208 const p = pattern.toLowerCase()
209 return p.startsWith('*') ? n.endsWith(p.slice(1)) : n === p
210}
211
212const title = (id: string) => id.charAt(0).toUpperCase() + id.slice(1)
213
214export type Tally = { name: string; files: number; exts: Map<string, number>; sample: string }
215
216/**
217 * Files per language, with the least nested file of each kept as its sample, and the file types
218 * that look like code but that neither the table nor any server places.
219 */
220export function tally(files: string[], servers: Server[] = []) {
221 const byKey = new Map<string, { id: string; name: string }>()
222 for (const l of LANGS) for (const k of [...l.exts, ...(l.names ?? [])]) byKey.set(k, l)
223 for (const s of servers)
224 for (const k of s.exts)
225 if (!byKey.has(k)) {
226 const id = s.langs[k] ?? k.replace(/^\./, '')
227 byKey.set(k, LANG_BY_ID.get(id) ?? { id, name: title(id) })
228 }
229 const names = new Set([...byKey.keys()].filter(k => !k.startsWith('.')))
230
231 const langs = new Map<string, Tally>()
232 const unplaced = new Map<string, number>()
233 const depth = (p: string) => p.split(/[\\/]/).length
234 for (const f of files) {
235 const base = baseName(f).toLowerCase()
236 const key = names.has(base) ? base : extOf(f)
237 const lang = byKey.get(key)
238 if (!lang) {
239 if (/^\.[a-z][a-z0-9_+-]{0,9}$/.test(key) && !NOT_CODE.has(key)) unplaced.set(key, (unplaced.get(key) ?? 0) + 1)
240 continue
241 }
242 const t = langs.get(lang.id) ?? { name: lang.name, files: 0, exts: new Map(), sample: f }
243 t.files++
244 t.exts.set(key, (t.exts.get(key) ?? 0) + 1)
245 if (depth(f) < depth(t.sample)) t.sample = f
246 langs.set(lang.id, t)
247 }
248 return { langs, unplaced }
249}
250
251/** The unknown file types, most files first, ten at most. */
252export function unplaced(files: string[], servers: Server[]): [string, number][] {
253 return [...tally(files, servers).unplaced].sort((a, b) => b[1] - a[1]).slice(0, 10)
254}
255
256/**
257 * The server that best covers a language's files: one that is on beats one that is only
258 * installed, which beats one that isn't installed; then the one taking the most of the files;
259 * then the official marketplace's.
260 */
261export function pick(exts: Map<string, number>, servers: Server[]): Server | undefined {
262 const rank = (s: Server) => (s.isInstalled && s.isEnabled ? 0 : s.isInstalled ? 1 : 2)
263 const covers = (s: Server) => [...exts].reduce((n, [e, c]) => n + (s.exts.includes(e) ? c : 0), 0)
264 return servers
265 .filter(s => covers(s) > 0)
266 .sort((a, b) => rank(a) - rank(b) || covers(b) - covers(a) || Number(b.isOfficial) - Number(a.isOfficial))[0]
267}
268
269/** How to put a server's command on PATH. */
270export function installHint(command: string, isWindows: boolean): string {
271 const hints: Record<string, string> = {
272 'csharp-ls': 'dotnet tool install --global csharp-ls',
273 'typescript-language-server': 'npm install --global typescript-language-server typescript',
274 'pyright-langserver': 'npm install --global pyright',
275 gopls: 'go install golang.org/x/tools/gopls@latest',
276 clangd: isWindows ? 'winget install LLVM.clangd' : 'install clangd with your package manager',
277 'lua-language-server': isWindows ? 'winget install LuaLS.lua-language-server' : 'install lua-language-server with your package manager',
278 'rust-analyzer': 'rustup component add rust-analyzer',
279 intelephense: 'npm install --global intelephense',
280 'ruby-lsp': 'gem install ruby-lsp',
281 jdtls: 'install Eclipse JDT LS and put jdtls on PATH',
282 'kotlin-lsp': 'install kotlin-lsp from github.com/Kotlin/kotlin-lsp and put it on PATH',
283 'sourcekit-lsp': 'install the Swift toolchain from swift.org, which ships sourcekit-lsp',
284 }
285 return hints[command] ?? `put ${command} on PATH`
286}
287
288export type Facts = {
289 files: string[]
290 /** Plugin servers and MCP servers alike. */
291 servers: Server[]
292 /** Plugin ids, by language id, that a marketplace offers without listing their servers. */
293 hinted: Record<string, string[]>
294 /** Where each server command was found on PATH, or null where it wasn't. */
295 binaries: Record<string, string | null>
296 /** Ids of the languages whose project file wasn't found. */
297 missingProject: string[]
298 isWindows: boolean
299}
300
301const ORDER: Record<Status, number> = { 'no-server': 0, disabled: 1, available: 2, ready: 3, mcp: 4, none: 5 }
302
303/** One row per language in the folder: problems, then the LSP tool's, then MCP's; code before data; then by file count. */
304export function rows(f: Facts): Row[] {
305 const out: Row[] = []
306 const plugins = f.servers.filter(s => !s.isMcp)
307 const mcps = f.servers.filter(s => s.isMcp)
308 for (const [id, t] of tally(f.files, f.servers).langs) {
309 const lang = LANG_BY_ID.get(id)
310 const notes = f.missingProject.includes(id) && lang?.project ? [lang.project.missing] : []
311 const base = { lang: id, name: t.name, files: t.files, sample: t.sample, notes }
312 const s = pick(t.exts, plugins)
313 const m = pick(t.exts, mcps)
314 const meanwhile = m ? [`The ${m.plugin} MCP server covers it read-only in the meantime.`] : []
315
316 if (!s) {
317 const hint = f.hinted[id]?.[0]
318 if (hint)
319 out.push({
320 ...base,
321 status: 'available',
322 plugin: hint,
323 fix: `claude plugin install ${hint}`,
324 notes: [...notes, 'Its marketplace entry names no server, so read its README for what else to install.', ...meanwhile],
325 })
326 else if (m)
327 out.push({
328 ...base,
329 status: 'mcp',
330 server: m.name,
331 mcp: m.plugin,
332 notes: [...notes, "No Claude Code plugin covers it, so the LSP tool can't. The MCP server's own tools can, read-only."],
333 })
334 else if (!lang?.isData) out.push({ ...base, status: 'none' })
335 continue
336 }
337
338 const left = [...t.exts.keys()].filter(e => !s.exts.includes(e))
339 if (left.length) notes.push(`${s.name} doesn't take ${left.join(', ')} files.`)
340 const binary = f.binaries[s.command] ?? undefined
341 const row = { ...base, plugin: s.plugin, server: s.name, command: s.command, binary }
342 const install = installHint(s.command, f.isWindows)
343
344 if (s.isInstalled && s.isEnabled) {
345 out.push(binary ? { ...row, status: 'ready' } : { ...row, status: 'no-server', fix: install, notes: [...notes, ...meanwhile] })
346 } else if (s.isInstalled) {
347 out.push({ ...row, status: 'disabled', fix: `claude plugin enable ${s.plugin}`, notes: [...notes, ...meanwhile] })
348 } else {
349 if (!binary) notes.push(`${s.command} isn't on PATH either: ${install}`)
350 out.push({ ...row, status: 'available', fix: `claude plugin install ${s.plugin}`, notes: [...notes, ...meanwhile] })
351 }
352 }
353 const isData = (r: Row) => Number(Boolean(LANG_BY_ID.get(r.lang)?.isData))
354 return out.sort((a, b) => ORDER[a.status] - ORDER[b.status] || isData(a) - isData(b) || b.files - a.files)
355}
356
357/** Languages with a way to get a server, and how many have one now: the LSP tool's, or an MCP server's alone. */
358export function coverage(rows: Row[]) {
359 const need = rows.filter(x => x.status !== 'none')
360 const ready = need.filter(x => x.status === 'ready').length
361 const mcp = need.filter(x => x.status === 'mcp').length
362 return { need: need.length, ready, mcp, covered: ready + mcp, hasMissingServer: need.some(x => x.status === 'no-server') }
363}
364
365export const LABEL: Record<Status, string> = {
366 ready: 'ready',
367 'no-server': 'server not on PATH',
368 disabled: 'plugin off',
369 available: 'plugin not installed',
370 mcp: 'MCP only',
371 none: 'no server',
372}
373
374const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
375
376/** The pane's first line and the command's: how much of the folder's code has a server. */
377export function headline(rows: Row[], folder: string): string {
378 const c = coverage(rows)
379 if (!c.need) return `No code in ${folder} that a language server covers.`
380 const viaMcp = !c.mcp ? '' : c.mcp === c.covered ? ', all through MCP only' : `, ${c.mcp} of them through MCP only`
381 return `Language servers cover ${c.covered} of ${plural(c.need, 'language')} in ${folder}${viaMcp}.`
382}
383
384/** The slash command's answer: the headline, then a line per language. The model reads it too. */
385export function summary(r: { rows: Row[]; folder: string; unplaced?: [string, number][] }): string {
386 const none = r.rows.filter(x => x.status === 'none')
387 const others = r.unplaced ?? []
388 return [
389 headline(r.rows, r.folder),
390 ...r.rows
391 .filter(x => x.status !== 'none')
392 .flatMap(x => [
393 `- ${x.name}: ${LABEL[x.status]}${x.server ? ` (${x.server})` : ''}.${x.fix ? ` Fix: \`${x.fix}\`` : ''}`,
394 ...x.notes.map(n => ` ${n}`),
395 ]),
396 ...(none.length ? [`- No plugin or MCP server covers ${none.map(n => n.name).join(', ')}.`] : []),
397 ...(others.length ? [`- Unknown file types with no server: ${others.map(([e, n]) => `${e} (${n})`).join(', ')}.`] : []),
398 ].join('\n')
399}
400
401/** The first match `where` or `which` printed, preferring a Windows executable over a bare shell script. */
402export function firstPath(stdout: string): string | undefined {
403 const lines = stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
404 return lines.find(l => /\.(exe|cmd|bat|com)$/i.test(l)) ?? lines[0]
405}
406
407/** What the prompt box gets from Ask Claude to fix: each problem and the line that fixes it. */
408export function fixRequest(rows: Row[], folder: string): string {
409 const lines = rows
410 .filter(r => r.fix)
411 .map(r => {
412 const why =
413 r.status === 'no-server'
414 ? `${r.command} isn't on PATH`
415 : r.status === 'disabled'
416 ? `${r.plugin} is installed but off`
417 : `no LSP plugin is installed`
418 return `- ${r.name} (${plural(r.files, 'file')}): ${why}. Fix: \`${r.fix}\``
419 })
420 return [
421 `Set up LSP for ${folder}. The lsp-status mod found:`,
422 ...lines,
423 'Run the fixes, then tell me which ones need a session restart before the LSP tool picks them up.',
424 ].join('\n')
425}
426types/index.d.ts 65 lines1/**
2 * Where a language in the folder stands. `ready`: an enabled plugin's server covers it and its
3 * command is on PATH. `no-server`: the plugin is on but the command isn't on PATH. `disabled`: the
4 * plugin is installed and switched off here. `available`: a marketplace you added has a plugin for
5 * it that isn't installed. `mcp`: no plugin covers it, but an MCP server's language server does,
6 * through that server's own tools. `none`: nothing covers it.
7 */
8export type Status = 'ready' | 'no-server' | 'disabled' | 'available' | 'mcp' | 'none'
9
10/** One language found in the folder and how its LSP is set up. */
11export type Row = {
12 lang: string
13 name: string
14 files: number
15 /** A file of the language, relative to the folder, that the Test button asks the server about. */
16 sample: string
17 status: Status
18 /** The plugin as `name@marketplace`. */
19 plugin?: string
20 /** The MCP server whose language server covers it, for `mcp`. */
21 mcp?: string
22 server?: string
23 command?: string
24 /** Where the command was found on PATH. */
25 binary?: string
26 /** The shell line that fixes it. */
27 fix?: string
28 notes: string[]
29}
30
31export type Report = {
32 at: number
33 cwd: string
34 folder: string
35 files: number
36 /** `git` lists tracked and unignored files; `walk` reads the folder and skips build output. */
37 via: 'git' | 'walk'
38 /** True when the walk hit its limit before it read the whole folder. */
39 isCapped: boolean
40 rows: Row[]
41 /** File types that look like code with no server and no entry in the language table, most files first. */
42 unplaced: [string, number][]
43 /** Each MCP server asked for its `lsp_status`, and what came of it. */
44 mcp: McpCheck[]
45}
46
47/**
48 * One MCP server's `lsp_status` answer: how many language servers it reported, or why this scan's
49 * call failed. `savedAt` is when the answer it fell back to arrived, when it had one.
50 */
51export type McpCheck = { server: string; servers: number; problem?: string; savedAt?: number }
52
53/** The answer the last Test press got from a language's server. */
54export type Probe = { state: 'running' | 'ok' | 'failed'; text: string }
55
56declare module 'claude-code' {
57 interface PluginState {
58 'lsp-status': {
59 report: Report | null
60 isScanning: boolean
61 probes: Record<string, Probe>
62 }
63 }
64}
65