Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` targets a single account; `--label` names that account's statusline and only applies with `-A`.
Expand All @@ -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`
Expand Down Expand Up @@ -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/<name>/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
Expand Down
39 changes: 38 additions & 1 deletion internal/plugins/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions internal/plugins/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down