Skip to content

About

clojure-lsp behind a proxy for Claude Code: Bash-made file changes reach the analysis, rename and format through the CLI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

7 Commits

Folders and files

Repository files navigation

clojure-lsp-proxy

A Claude Code plugin that runs clojure-lsp behind a small Babashka proxy. Claude Code's native LSP client talks to the proxy over stdio; the proxy talks to clojure-lsp. The proxy exists to keep navigation correct after files change outside Claude's Edit and Write tools (shell commands, formatters, git commands that move the working tree), and to offer a rename that runs in the same long-lived clojure-lsp.

Status: all five phases of the plan are implemented: the transparent proxy with a traffic log (C1), progress ownership (C2), change injection with gating (C3), change reports (C4), the control socket (C5), the hooks that bracket every Bash command (C6) and rename (C7), plus format (C8) and clean-ns (C9).

Layout

The repository is the plugin. .claude-plugin/plugin.json is the manifest, .lsp.json the LSP server configuration, bin/ the executables Claude Code and hooks run, src/ the Babashka code, test/ the suites (bb test).

  • bin/clojure-lsp-proxy-server: the LSP entry point. Starts the server named by CLOJURE_LSP_PROXY_SERVER (default clojure-lsp on PATH) with the proxy's own arguments and proxies stdio.
  • bin/clojure-lsp-proxy: the command line over the control socket (status, changed [--wait] <path>..., rename, format, clean-ns).
  • skills/clojure-rename/SKILL.md, skills/clojure-format/SKILL.md, skills/clojure-clean-ns/SKILL.md: tell Claude to use the rename, format and clean-ns commands instead of editing by hand.
  • src/clojure_lsp_proxy/rename.clj, format.clj: rename and formatting through the running clojure-lsp; command.clj: clojure-lsp refactor commands (workspace/executeCommand, so far clean-ns) and the workspace/applyEdit the server sends for them; edit.clj applies the resulting workspace edit, reports the touched files and waits for the analysis.
  • bin/on-bash-start, bin/on-bash-end, hooks/hooks.json: the Claude Code hooks around every Bash command (PreToolUse, PostToolUse and PostToolUseFailure).
  • src/clojure_lsp_proxy/detect.clj: which files a Bash command changed, from a git snapshot before it and a check after it (adapted from rule-fairy).
  • src/clojure_lsp_proxy/hooks.clj: the hook scripts' logic.
  • src/clojure_lsp_proxy/main.clj: process lifecycle and the two pumps.
  • src/clojure_lsp_proxy/gate.clj: progress ownership, the send queue and the gate (C2, C3).
  • src/clojure_lsp_proxy/changes.clj: which paths are reported and how (C4).
  • src/clojure_lsp_proxy/control.clj, client.clj, cli.clj: the socket's server side, its client and the command line (C5).
  • src/clojure_lsp_proxy/framing.clj, transport.clj, log.clj, project.clj: Content-Length framing, locked writes with logging, the traffic log and the per-project directory under ~/.cache/clojure-lsp-proxy/.

Running it

Clone the repository anywhere; <clone> below stands for that directory. It needs Babashka (bb on PATH, or in ~/.local/bin, /opt/homebrew/bin or /usr/local/bin) and a clojure-lsp (see "The server").

git clone https://github.com/Cyrik/clojure-lsp-proxy <clone>

The repository is its own marketplace (.claude-plugin/marketplace.json, one plugin with source ./). Installed once with

claude plugin marketplace add <clone>
claude plugin install clojure-lsp-proxy@clojure-lsp-proxy

every new session loads it, whatever started Claude Code. A relative-path plugin of a directory marketplace runs in place: the proxy's start event logs CLAUDE_PLUGIN_ROOT as <clone>, so git pull (or an edit in the clone) is live in the next session; claude plugin update only acts on a version change. For a one-off session, claude --plugin-dir <clone> works too and registers before any installed plugin.

The proxy needs clojure-lsp 2026.10.15 or newer (clojure-lsp --version), the first release that reports watched-file analysis progress (clojure-lsp#2474). With an older one, navigation works but every reported change holds requests for the 30 s deadline, and the log says so (see "The server").

The marketplace can also come straight from GitHub, claude plugin marketplace add Cyrik/clojure-lsp-proxy, with the same install command. That installs a copy in Claude Code's plugin cache instead of running in place, so it upgrades only when the plugin's version changes (claude plugin marketplace update clojure-lsp-proxy, then claude plugin update clojure-lsp-proxy@clojure-lsp-proxy).

Only one plugin may serve .clj: Claude Code gives each extension to the first LSP server registered and logs the losers under --debug ("extension .clj already handled by ..."). Any other plugin that registers an LSP server for .clj (for example clojure-lsp-cc) has to be disabled with claude plugin disable <plugin>@<marketplace>.

.lsp.json sets startupTimeout to five minutes because clojure-lsp runs the whole project analysis before it answers initialize.

The server

The gate needs a clojure-lsp that reports watched-file analysis as work-done progress (clojure-lsp#2474): release 2026.10.15 or newer, or any nightly built from master since 2026-10-05. A release clojure-lsp on PATH is the default and needs no configuration; check it with clojure-lsp --version. Package managers such as Homebrew may ship a new release some time after it is published.

With an older clojure-lsp, navigation works but no analysis progress ever arrives: every reported change holds the client's requests for the full deadline (default 30 s), the log says so (send-dropped with a hint), and rename and format are refused after the first change report because no analysis confirms it.

The proxy runs CLOJURE_LSP_PROXY_SERVER when that variable is set (a path, or a command on PATH), else clojure-lsp on PATH. A path that is not an executable file is ignored: the proxy logs server-fallback and runs clojure-lsp from PATH, so a stale setting degrades instead of failing to start. Claude Code sets the variable for every session from the env block of ~/.claude/settings.json:

"env": { "CLOJURE_LSP_PROXY_SERVER": "/Users/you/.local/share/clojure-lsp-nightly/clojure-lsp" }

The variable is optional. One use is running a build newer than the latest release: clojure-lsp publishes a nightly (a native image) for every commit on master, and its install script fetches the newest one, without sudo when given a directory:

curl -O https://raw.githubusercontent.com/clojure-lsp/clojure-lsp/master/install
chmod +x install && ./install --version nightly --dir ~/.local/share/clojure-lsp-nightly

Point the variable at the clojure-lsp it installs, as in the example above (a directory off PATH leaves the release in place for other tools); rerunning the script upgrades.

The traffic log is a JSONL file at ~/.cache/clojure-lsp-proxy/<sha1 of the project root>/<proxy pid>.log, or at CLAUDE_PLUGIN_LSP_LOG_FILE when that variable is set (the test suites set it; the current CLI has no flag for it). Every line has t and ms (wall-clock time), a dir (c->s, s->c for forwarded messages; p->s, s->p for messages the proxy itself sends or consumes; proxy for its own events; server-stderr for the server's stderr lines) and, for messages, method, id and the full body.

The project root is CLAUDE_PROJECT_DIR when set (Claude Code sets it for plugin LSP servers and hooks), else the current directory. The control socket sits next to the log as <proxy pid>.sock.

Proxy events in the log (dir proxy, field event): gate-close, send-start, send-queued, hold, send-analysis-begin, send-end (with its reason: analysis-end, deadline or shutdown), gate-open (with closed-ms and the number of released messages), anomaly (a progress create that cannot belong to the send in flight), send-dropped (no analysis by the deadline, with a hint that the server may predate the progress reports, or no end), resend-unconfirmed, rename-applied, format-applied, clean-ns-applied, apply-edit-consumed (a server workspace/applyEdit the proxy answered itself during its own command, with the command; an anomaly of kind consumed-edit-differs-from-result when that edit is not the command's result), server-fallback (a CLOJURE_LSP_PROXY_SERVER path that is not an executable file), plus the lifecycle events start, client-eof, server-exit, terminated, exit. A released message is logged with its held-ms.

CLOJURE_LSP_PROXY_SEND_DEADLINE_MS (default 30000) bounds how long one send can keep the gate closed; a value that is not a positive integer is ignored with a warning.

How a Bash command's changes reach clojure-lsp

Claude's Edit and Write tools need nothing: Claude Code's LSP client sends didOpen, didChange and didSave for them. For the Bash tool, the plugin's PreToolUse hook sends bash-started and the proxy checks the project's git state (a marker time, git status, HEAD) against the previous checkpoint, queues changes made between commands, and saves the new snapshot for the bracket; the PostToolUse and PostToolUseFailure hooks send bash-finished with the bashEditDiff Claude Code recorded, and the proxy unions that with its own check against the snapshot: paths new to the status listing, paths rewritten or removed since the marker, paths that left the listing because a stash, checkout or restore rewrote them, the old names of renames (and an old name that a revert of the rename brought back), and what the commits between the two HEADs touched (for paths that were clean before). The result goes through the C4 filter and into the send queue, and the hook returns as soon as the report is queued. Snapshots are keyed by Claude Code's tool_use_id, since parallel Bash calls interleave their hooks; a finish whose start never arrived uses the previous check's snapshot, or the one taken at startup. Checks at Bash start, Bash finish and before rename share a checkpoint. Each scan and report is serialized with the checkpoint update, so overlapping brackets cannot advance it past an unreported interval. Analysis runs independently of these checks. Content fingerprints of reported files suppress duplicate reports from overlapping checks; the proxy does not hash the whole project.

Set bashEditDiffEnabled to true in ~/.claude/settings.json so that Claude Code records its diff in every permission mode; without it the git check catches git-visible changes made between commands by the next Bash start or rename, including writes from editors and background commands. Known gaps: native LSP queries before that next check can still see stale analysis; automatic disk checks do not cover projects outside git; files git ignores are never discovered by the git check, even a gitignored .clj under a source path that clojure-lsp analyzed at startup; nested repositories show up as one untracked directory; a file written in the same second as a snapshot may be reported once more than necessary.

Reporting a change by hand, from the project:

/path/to/clojure-lsp-proxy/bin/clojure-lsp-proxy changed --wait src/my/ns.clj
/path/to/clojure-lsp-proxy/bin/clojure-lsp-proxy status

Rename

clojure-lsp-proxy rename <file> <line> <col> <new-name> [--apply]
clojure-lsp-proxy rename --symbol <ns/name> <new-name> [--apply]
clojure-lsp-proxy rename --symbol <ns> <new.ns> [--apply]

The proxy asks the running clojure-lsp for the rename; for a namespace it also asks workspace/willRenameFiles for the ns form and :require edits that go with the file move, as an editor does. Lines and columns are one-based. --symbol resolves vars and namespaces through workspace/symbol; keywords and locals have no workspace symbol and take the position form. Before asking for the rename the proxy asks clojure/cursorInfo/raw what sits at the position and refuses the key of a destructuring map (:keys, :syms, :strs, qualified or not), which clj-kondo marks as such; its rename would be one edit that breaks the destructuring instead of renaming anything. The same spelling in plain data, {:api/keys [1 2]}, carries no mark and is renamed. Without --apply the edits are printed and nothing changes. With --apply the proxy applies them (text edits from the last position backwards, then file operations), reports every touched path through C4, waits for the re-analysis and then replies; the command line also tells every other proxy of the project about the touched files. A file rename or creation that would overwrite an existing file fails before anything is written. Before asking clojure-lsp for the edits, both dry-run and applied rename check for git-visible disk changes since the shared checkpoint and queue them. The rename then waits until every change the proxy reported has been analyzed (the gate's sends are settled; status shows this as settled), at most two deadlines, and is refused when they are not. It is also refused, with nothing sent to clojure-lsp, while clojure-lsp has not finished initialize (status shows server_ready false). When the wait after an applied rename runs out before the re-analysis is confirmed, the command line says the analysis is unconfirmed and still exits 0. An open gate is not enough: after a deadline released the held messages, the analysis of a reported change may still be running. A send that got no analysis by its deadline, or whose analysis never ended, leaves its changes unconfirmed (status: unconfirmed_paths); the rename re-sends them first and proceeds only once a send ends on its analysis. With a server that reports no progress for watched-file changes (clojure-lsp before 2026-10-05, or :compute-external-file-changes false) nothing ever confirms, so the rename stays refused after the first change report: a current clojure-lsp with default settings is required. The disk check is a point-in-time check: writes made after it, ignored files and files outside git are not covered by it.

Format

clojure-lsp-proxy format <file> [--apply]

The file's textDocument/formatting edits from the running clojure-lsp, cljfmt with the project's settings as clojure-lsp reads them. Without --apply they are printed; with --apply they go through the same path as a rename: checked before writing, reported, waited for. A file that is already formatted yields no edits and is not written. Whole files only. Like rename, format is refused before clojure-lsp has finished initialize and reports an unconfirmed analysis when the wait runs out.

Clean ns

clojure-lsp-proxy clean-ns <file> [--apply]

clojure-lsp's clean-ns refactoring, run as workspace/executeCommand in the running clojure-lsp: unused requires, refers and imports removed, the rest sorted, with the project's clean-ns settings. Without --apply the new ns form is printed; with --apply it goes through the same path as a rename: checked before writing, reported, waited for. clojure-lsp always proposes the whole ns form, so an already clean file still shows one edit in the dry run; applied, that edit leaves the text as it is, so the file is not written and no path is reported. A file without an ns form gets no edit from clojure-lsp and is refused ("clean-ns proposed no edit"). Like rename and format, clean-ns waits for every reported change to be analyzed first, is refused before clojure-lsp has finished initialize, and reports an unconfirmed analysis when the wait after applying runs out.

clojure-lsp hands a command's edit to the client as a workspace/applyEdit request and returns the same edit as the command's result. Claude Code does not answer that request, so while one of its own commands is in flight the proxy answers it (applied: false) instead of forwarding it, and applies the result itself.

Contracts

  • C1 Transparency. When no change has been reported, every message crosses the proxy unchanged except the initialize params patch (C2's progress capability and, for C7, workspace.workspaceEdit with documentChanges and resource operations, which only the proxy's own requests exercise) and the progress ownership of C2, and except a server workspace/applyEdit that arrives while one of the proxy's own commands is in flight (C9). If the server process dies unexpectedly, the proxy answers every held request with an InternalError (-32603) response and exits non-zero so that Claude Code's restartOnCrash restarts both. After a forwarded exit, or after the client closes stdin, the proxy exits 0: it sends shutdown and exit to the server if the client did not, waits briefly, kills it if needed. Claude Code 2.1.281 sends shutdown and then terminates the proxy with SIGTERM instead of sending exit; the proxy's shutdown hook logs that and the server ends when its stdin closes.
  • C2 Progress ownership. The proxy sets capabilities.window.workDoneProgress to true in the initialize params it forwards, answers every window/workDoneProgress/create request from the server itself with a null result, never forwards those requests, and does not forward $/progress notifications for tokens it accepted. Progress on any other token, including the client's initialize.workDoneToken, passes through. The begin title is recorded per token.
  • C3 Gating. The proxy is the only source of workspace/didChangeWatchedFiles and keeps at most one send in flight: changes reported while a send is in flight are coalesced and sent as the next notification once the current one has ended. The gate is closed from the moment a change report is accepted until no send is in flight or pending. While closed, the proxy holds every client-to-server message in arrival order except responses, $/cancelRequest, initialize, initialized, shutdown and exit; shutdown opens the gate. A $/cancelRequest for a held request removes it and answers RequestCancelled (-32800). A send ends when the end of a token arrives whose begin title is "Analyzing external file changes" and whose create arrived after the send. A deadline (default 30 s) bounds every hold: a held message is released at the latest one deadline after the gate closed, whatever the sends are doing, and a message held after such a release starts a fresh closure with its own bound. A send whose analysis had begun by its deadline stays outstanding until its end (or ten deadlines later), so the next send starts only after that batch ended and never ends on an earlier send's batch; a send for which no analysis began by the deadline gets none, since nothing else was being analyzed, and is dropped. A dropped send's changes stay unconfirmed: they join the next send and count as analyzed only when a send ends on its analysis; they do not hold the gate. changed --wait reports a timeout when its report was still pending at the release. Reports before the client's initialized wait for it. A create arriving under 900 ms after a send cannot belong to that send's batch (the server debounces for 1000 ms) and is logged as an anomaly, as is any create of that title arriving while no send is outstanding. Reports after shutdown are dropped. Every hold is logged with its duration and the reason the gate opened.
  • C4 Change reports. Reported paths are filtered to the server's watched extensions (clj cljs cljc cljd edn bb clj_kondo) and to the project root, and sent as file:// URIs of the real (symlink-resolved) path. Paths under clojure-lsp's ignored source paths (its :source-paths-ignore-regex, read from the project's .lsp/config.edn, else the user's clojure-lsp config.edn, else the default target.*) are left out too, since clojure-lsp would not analyze them and the gate would wait for its deadline; a regex set through initializationOptions is not seen. Type is deleted when the path no longer exists and changed otherwise (clojure-lsp treats created and changed alike). Deletions come from listing differences, not from file times: a status entry newly D, a path that left the listing and no longer exists, and the origin path of a rename or copy entry. A path that left the listing but exists (reverted by stash, restore or checkout) is changed. An empty report sends nothing and gates nothing.
  • C5 Control socket. Each proxy listens on a Unix domain socket at ~/.cache/clojure-lsp-proxy/<sha1 of the real project root path>/<pid>.sock and removes it on exit. Protocol: one JSON object per line in each direction, one request per connection. Operations: status, bash-started, bash-finished, changed, rename, format, refactor. bash-started and bash-finished reply as soon as the proxy has snapshotted or queued, never after analysis; changed waits for the gate only when asked. Clients address every socket in the project's directory and delete a socket whose connection is refused. The project root is CLAUDE_PROJECT_DIR when set, else the current directory; the command line, when the variable is unset, looks under the current directory and each of its parents up to the git top level. A changed --wait outstanding when the proxy exits gets no reply; the command line prints "reply":null for that socket and exits 1.
  • C6 Hooks never fail Claude. Each hook exits 0 and prints nothing on stdout whatever happens; it gives up on a proxy after 5 s. Errors go to stderr.
  • C7 Rename. Without --apply the CLI prints the edits clojure-lsp returned and changes nothing. Either way the rename first re-sends the changes a dropped send left unconfirmed, then is refused unless every change the proxy reported has been analyzed within two deadlines (C3's sends settled: nothing in flight, pending or unconfirmed), so that the edits never come from an analysis behind a reported change. Before that, the rename is refused with nothing sent to the server while clojure-lsp has not finished initialize (the client's initialized has not been forwarded; status: server_ready false). With --apply the proxy applies the workspace edit to disk, reports the touched paths through C4, waits for that send to end per C3, and then replies; when the wait runs out first the reply carries timed_out true, and the CLI prints that the analysis was not confirmed and still exits 0: clojure-lsp did not confirm its analysis of the written files within the wait. The next edit command first waits for that analysis, re-sending the changes if no analysis is running, and is refused if it is still not confirmed within two deadlines. Every text edit is computed and checked against the file, and every file rename or creation checked for an existing target, before anything is written; a failure while writing leaves the files written so far in place, and those paths are still reported. A rename applied inside a Bash command's bracket is not reported again by that bracket, unless the file was written again afterwards, even with the same content. --symbol names a var or a namespace; keywords and locals are renamed by position. A rename aimed at the key of a destructuring map (:keys, :syms, :strs, qualified or not, as clj-kondo marks them) is refused before anything is requested; the same spelling in plain data is renamed.
  • C8 Format. format <file> asks the running clojure-lsp for the file's textDocument/formatting edits. Without --apply the CLI prints them and changes nothing; with --apply they go through C7's path: checked before writing, reported through C4, waited for per C3, and not reported again by the Bash bracket around the command. A file without edits is not written, and neither is one whose edits leave its text unchanged (true of every applied edit, not only format's). Like rename, format waits for every reported change to be analyzed first.
  • C9 Clean ns. clean-ns <file> runs clojure-lsp's clean-ns command through workspace/executeCommand (control op refactor). Without --apply the CLI prints the edit and changes nothing; with --apply the edit goes through C7's path: checked before writing, reported through C4, waited for per C3, and not reported again by the Bash bracket. Like rename, it waits for every reported change to be analyzed first. The proxy runs its own commands one at a time. While one is in flight, a workspace/applyEdit request from the server is answered by the proxy with applied: false and a failureReason, never forwarded, and the edit applied is the command's result; a consumed edit that differs from the result is logged as an anomaly. A workspace/applyEdit while no own command is in flight crosses to the client unchanged and its response to the server. A file whose edit leaves its text unchanged (an already clean ns form) is not written and not reported. The command is refused when the file does not exist, when clojure-lsp answers with an error, and when it proposes no edit (a file without an ns form).

Development

bb test runs the unit suites (framing, change reports, detection against temporary git repositories) and three end-to-end suites, which start the proxy as a subprocess in front of test/fake_server.bb and play the client on its stdio; the gate suite also drives the control socket and the hook scripts, the rename suite uses canned server answers and checks the files. The fake server emulates clojure-lsp's progress sequence on request (create after the debounce, begin, end), so the suites take about a minute of real waiting.

About

clojure-lsp behind a proxy for Claude Code: Bash-made file changes reach the analysis, rename and format through the CLI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages