From d4c8a18e925ab2ba2d5b7fc9e2c2f69251aea36d Mon Sep 17 00:00:00 2001 From: Tanq16 <37408906+Tanq16@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:19:09 -0400 Subject: [PATCH] plugin: ship Go/Python/TS language servers in the global plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a .lsp.json to the global plugin that configure and launch build, so Claude gets go-to-definition, find-references, and post-edit type diagnostics in every session by default — no build step, no -P. writeGlobalLSP mirrors writeGlobalManifest: a fixed JSON file at the plugin root, rewritten every build (not refresh-gated like skills and styles) so an added server or schema change reaches existing installs. All three ship unconditionally; Claude Code skips a server whose binary is absent, so no toolchain detection is needed. Install: gopls (go install); pyright and typescript-language-server plus typescript@5 (npm). The @5 pin matters — typescript@latest is 7.x, which dropped the tsserver binary the language server drives. --- README.md | 21 +++++++++++++---- internal/plugins/plugins.go | 39 +++++++++++++++++++++++++++++++- internal/plugins/plugins_test.go | 33 +++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3cbf618..bba3da7 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ No command needs a flag. Run `claudex configure` once, then `claudex launch` in Run this once, right after installing. With no arguments it provisions **every account it discovers** in a single pass: - **Per account** — a statusline and a set of opinionated `settings.json` defaults. Your existing settings and env vars are preserved; only ClaudeX's keys are merged in. -- **The global plugin** — built at `~/.config/claudex/global` and shared by every account, so its content is in every session with no per-account setup: the `caveman` output style plus the `cross-ai` and `ai-docs` skills. Anything you drop into its `skills/` or `output-styles/` rides along the same way. +- **The global plugin** — built at `~/.config/claudex/global` and shared by every account, so its content is in every session with no per-account setup: the `caveman` output style, the `cross-ai` and `ai-docs` skills, and a `.lsp.json` that wires up Go, Python, and TypeScript language servers (see below). Anything you drop into its `skills/` or `output-styles/` rides along the same way. - **Flavors** — creates `~/.config/claudex/flavors/` for your launch-time system-prompt postures (see [`launch`](#launch)). `-A ` targets a single account; `--label` names that account's statusline and only applies with `-A`. @@ -110,6 +110,16 @@ claudex configure claudex configure -A ~/.claude2 --label prod ``` +**Language servers ship on by default.** The global plugin's `.lsp.json` gives Claude go-to-definition, find-references, and type errors after every edit — no build step — in every session. You install the binaries yourself; a server whose binary is missing is skipped and the rest still start, so a partial install is fine, and `/plugin` (Errors tab) is where a missing one is reported. + +| Language | Binary | Install | +|---|---|---| +| Go | `gopls` | `go install golang.org/x/tools/gopls@latest` | +| Python | `pyright-langserver` | `npm install -g pyright` | +| TypeScript / JS | `typescript-language-server` | `npm install -g typescript-language-server typescript@5` | + +`typescript` is a second package, not a typo — the server drives `tsserver` and ships without it, resolving it from the project's `node_modules` first and falling back to the global install. The `@5` pin matters: `typescript@latest` is 7.x, which no longer ships a `tsserver` binary, and the language server refuses to start against it. Language servers are not MCP, so `--mcp none` leaves them running; `claude --bare` is what skips them. + **Accounts are found, never created.** ClaudeX picks up `~/.claude` and any numbered sibling (`~/.claude2`, `~/.claude3`, …). To add one, point Claude Code at a fresh directory and log in there — `CLAUDE_CONFIG_DIR=~/.claude2 claude` — then run `configure` again. ### `launch` @@ -145,18 +155,19 @@ The global plugin loads every launch, so your global skills and output style are | `default.md` + others | pick one (`default` pre-selected) or None | | others, no `default.md` | pick one or None | -**Plugins are how you bring your own skills and MCP servers.** `-P/--plugins` takes a local directory or a git URL (repeatable). Git repos are shallow-cloned — or shallow-updated if already fetched — under `~/.config/claudex/plugins`, and loaded alongside the global one. No marketplace, no version pinning, no orphaned copies: you get whatever is at the tip. A plugin is just a directory: +**Plugins are how you bring your own skills, MCP servers, and language servers.** `-P/--plugins` takes a local directory or a git URL (repeatable). Git repos are shallow-cloned — or shallow-updated if already fetched — under `~/.config/claudex/plugins`, and loaded alongside the global one. No marketplace, no version pinning, no orphaned copies: you get whatever is at the tip. A plugin is just a directory: ``` my-plugin/ ├── .claude-plugin/plugin.json # {"name": "my-plugin", "version": "1.0.0"} ├── skills//SKILL.md # skills -└── .mcp.json # MCP servers, same schema as a project .mcp.json +├── .mcp.json # MCP servers, same schema as a project .mcp.json +└── .lsp.json # language servers, keyed by name ``` -Any subset works — skills only, MCPs only, both. Splitting them across plugins is how you keep separately loadable sets (an MCP-only plugin you pull in for one session, say). This repo is itself one: **`claudex-dev`**, carrying ClaudeX's opinionated Go/Node development skills. +Any subset works — skills only, MCPs only, language servers only, or any mix. Splitting them across plugins is how you keep separately loadable sets (an MCP-only plugin you pull in for one session, say). This repo is itself one: **`claudex-dev`**, carrying ClaudeX's opinionated Go/Node development skills. -`--mcp none` suppresses **every** MCP server for the session, including any a loaded plugin declares — so an MCP-carrying plugin still leaves you one flag away from a clean session. +`--mcp none` suppresses **every** MCP server for the session, including any a loaded plugin declares — so an MCP-carrying plugin still leaves you one flag away from a clean session. It does not touch language servers, which are not MCP; `claude --bare` is what skips those. The global plugin already carries a `.lsp.json` for Go, Python, and TypeScript (see [`configure`](#configure)), so a `-P` plugin only needs its own for a language beyond those. ```bash claudex launch diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go index b385cb5..c9b457d 100644 --- a/internal/plugins/plugins.go +++ b/internal/plugins/plugins.go @@ -69,6 +69,9 @@ func BuildGlobalPlugin(dir string, skillsFS, outputStylesFS fs.FS, refresh bool) if err := writeGlobalManifest(dir); err != nil { return err } + if err := writeGlobalLSP(dir); err != nil { + return err + } skills, err := fs.ReadDir(skillsFS, "default-skills") if err != nil { @@ -109,7 +112,7 @@ func writeGlobalManifest(dir string) error { } data, err := json.MarshalIndent(map[string]any{ "name": "claudex", - "description": "claudex's curated skills and output styles, auto-loaded across every account", + "description": "claudex's curated skills, output styles, and language servers, auto-loaded across every account", "version": "0.0.1", }, "", " ") if err != nil { @@ -119,6 +122,40 @@ func writeGlobalManifest(dir string) error { return writeFileAtomic(manifest, data, 0o644) } +// Rewritten every build (not write-if-missing like skills/styles) so an added server or schema change reaches existing installs. A server whose binary is absent is skipped by Claude Code, so shipping all three by default is safe. +func writeGlobalLSP(dir string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(map[string]any{ + "go": map[string]any{ + "command": "gopls", + "args": []string{"serve"}, + "extensionToLanguage": map[string]string{".go": "go"}, + }, + "python": map[string]any{ + "command": "pyright-langserver", + "args": []string{"--stdio"}, + "extensionToLanguage": map[string]string{".py": "python", ".pyi": "python"}, + }, + "typescript": map[string]any{ + "command": "typescript-language-server", + "args": []string{"--stdio"}, + "extensionToLanguage": map[string]string{ + ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", + ".tsx": "typescriptreact", + ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", + ".jsx": "javascriptreact", + }, + }, + }, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return writeFileAtomic(filepath.Join(dir, ".lsp.json"), data, 0o644) +} + func installTree(srcFS fs.FS, root, dest string, refresh bool) error { if _, err := os.Stat(dest); err == nil && !refresh { return nil diff --git a/internal/plugins/plugins_test.go b/internal/plugins/plugins_test.go index c3afc6f..869559a 100644 --- a/internal/plugins/plugins_test.go +++ b/internal/plugins/plugins_test.go @@ -182,6 +182,18 @@ func TestBuildGlobalPluginInstallsEverything(t *testing.T) { if got := read(t, filepath.Join(dir, "output-styles", "robot.md")); got != "robot v1" { t.Fatalf("robot style = %q", got) } + + var lsp map[string]struct { + Command string `json:"command"` + } + if err := json.Unmarshal([]byte(read(t, filepath.Join(dir, ".lsp.json"))), &lsp); err != nil { + t.Fatalf(".lsp.json is not valid JSON: %v", err) + } + for name, want := range map[string]string{"go": "gopls", "python": "pyright-langserver", "typescript": "typescript-language-server"} { + if lsp[name].Command != want { + t.Fatalf(".lsp.json %s.command = %q, want %q", name, lsp[name].Command, want) + } + } } func TestBuildGlobalPluginRefreshVsWriteIfMissing(t *testing.T) { @@ -227,6 +239,27 @@ func TestBuildGlobalPluginRefreshVsWriteIfMissing(t *testing.T) { } } +func TestBuildGlobalPluginAlwaysRewritesLSP(t *testing.T) { + dir := filepath.Join(t.TempDir(), "global") + skills, styles := testGlobalFS() + if err := BuildGlobalPlugin(dir, skills, styles, false); err != nil { + t.Fatalf("initial build error = %v", err) + } + + lspPath := filepath.Join(dir, ".lsp.json") + if err := os.WriteFile(lspPath, []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + + // Unlike skills/styles, .lsp.json is rewritten even without refresh so a schema change reaches existing installs. + if err := BuildGlobalPlugin(dir, skills, styles, false); err != nil { + t.Fatalf("rebuild error = %v", err) + } + if got := read(t, lspPath); got == "stale" { + t.Fatal(".lsp.json was not rewritten on a no-refresh build") + } +} + func TestBuildGlobalPluginRefreshIsCleanSwap(t *testing.T) { dir := filepath.Join(t.TempDir(), "global") skills, styles := testGlobalFS()