SLOPSHOPPER

lsp-status

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

newpanebandcommandtoaststatus
v0.1.0MITupdated 2026-10-19shin2344234/claude-lsp-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lsp-status
│ ┃ LSP ✕ › fix the failing auth test and add an audit log call │ ┃ app [ Resc │ ┃ No code in app that a language server ⏺ Read(src/auth.ts) │ ┃ covers. 0 files read via git. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lsp-status │ ⎿ lsp-status: No code in app that a language server covers. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · LSP
app [ No code in app that a language server covers. 0 files read via git.
README

LSP status

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.

What it shows

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:

  • ready: an enabled plugin covers it and its server is on PATH
  • server not on PATH: the plugin is on, the program it starts is missing
  • plugin off: the plugin is installed and disabled for this folder
  • plugin not installed: a marketplace you added has a plugin for it
  • MCP only: no plugin covers it, but an MCP server's language server does

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.

Where it looks

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:

  • the plugins in each marketplace you added, from the servers their marketplace entries declare
  • installed plugins whose entries list no servers, read from the plugin's own plugin.json or .lsp.json
  • plugin folders named in CLAUDE_CODE_PLUGIN_DIRS
  • plugins under ~/.claude/skills and the project's .claude/skills
  • MCP servers with an lsp_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.

Limits

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.

Install

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.

Development

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.

License

MIT

Source 3 files
hooks/register.tsx 477 lines
1import { 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}
477
hooks/scan.ts 426 lines
1import 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}
426
types/index.d.ts 65 lines
1/**
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