baldart 3.13.0 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/VERSION +1 -1
- package/framework/docs/LSP-LAYER.md +66 -9
- package/package.json +1 -1
- package/src/commands/add.js +24 -0
- package/src/commands/configure.js +44 -0
- package/src/commands/doctor.js +112 -6
- package/src/commands/push.js +0 -0
- package/src/commands/update.js +23 -0
- package/src/utils/git.js +93 -0
- package/src/utils/lsp-adapters/go.js +1 -1
- package/src/utils/lsp-adapters/python.js +1 -1
- package/src/utils/lsp-adapters/ruby.js +1 -1
- package/src/utils/lsp-adapters/rust.js +1 -1
- package/src/utils/lsp-adapters/typescript.js +8 -2
- package/src/utils/lsp-installer.js +243 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ All notable changes to BALDART will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.14.0] - 2026-05-23
|
|
9
|
+
|
|
10
|
+
BALDART closes the **LSP install completeness gap** AND adds a long-overdue **`.framework/` gitignore self-heal** so install / update / push never trip over a stale or pre-existing ignore rule.
|
|
11
|
+
|
|
12
|
+
### LSP install completeness
|
|
13
|
+
|
|
14
|
+
Since v3.10.0, opting into `features.has_lsp_layer: true` installed the language-server binary (typescript-language-server, pyright, gopls, rust-analyzer, ruby-lsp) into the consumer repo — but the matching native Claude Code plugin (`<lang>-lsp@claude-plugins-official`) was left for the user to install manually via `/plugin install`. Almost nobody did, so Claude itself never invoked the LSP and code-exploration silently fell back to Grep. The "opt-in to LSP" UX was effectively broken: users thought they had it, agents behaved as if they didn't. v3.14.0 wires the two layers together — when the user opts into LSP, BALDART now installs the binary AND enables the native plugin in the same `configure` step, idempotently and gated on `claude` being in `tools.enabled`.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`claudePluginId()` returns real plugin ids** in every LSP adapter (`src/utils/lsp-adapters/{typescript,python,go,rust,ruby}.js`). Previously a `null` placeholder; now resolves to `typescript-lsp@claude-plugins-official`, `pyright-lsp@claude-plugins-official`, `gopls-lsp@claude-plugins-official`, `rust-analyzer-lsp@claude-plugins-official`, `ruby-lsp@claude-plugins-official` respectively. All plugins live in the Anthropic-published [`anthropics/claude-plugins-official`](https://github.com/anthropics/claude-plugins-official) marketplace.
|
|
19
|
+
- **`LspInstaller.installClaudePlugins(serverNames, opts)`** in `src/utils/lsp-installer.js` — high-level installer that (1) verifies the `claude` CLI is on PATH, (2) idempotently registers the `claude-plugins-official` marketplace via `claude plugin marketplace add anthropics/claude-plugins-official --scope user` (no-op when already registered — list is parsed first), (3) reads `claude plugin list --json` to skip plugins already installed at any scope, (4) runs `claude plugin install <id> --scope user` per missing plugin. Defaults to `--scope user` so a single install is reused across every project on the machine (LSP plugins are machine-wide useful — project scope would create N copies). Always returns `{ installed, skipped, manual, marketplace }`; never throws.
|
|
20
|
+
- **`LspInstaller.ensureClaudeMarketplace()`, `isClaudeCliAvailable()`, `listInstalledClaudePlugins()`, `verifyClaudePlugins(serverNames)`** — supporting primitives, all idempotent and degrade-open (parsing failure returns empty set → next install is attempted rather than incorrectly skipped).
|
|
21
|
+
- **`baldart configure` companion-plugin step** — after installing the language servers, the configure flow now calls `installClaudePlugins(merged.lsp.installed_servers)`. Surfaces clear messaging for each branch: success (✓ Installed plugin ids), idempotent skip ("Already installed"), Claude CLI missing (manual install commands printed verbatim per plugin), or `claude` opted out of `tools.enabled` (silent skip — Codex-only users don't want Claude marketplace mutation).
|
|
22
|
+
- **`baldart doctor` companion-plugin diagnostic** — `detectState` now calls `verifyClaudePlugins` when LSP is enabled and the `claude` CLI is available, populating `state.lspMissingClaudePlugins`. The `LSP layer` row in the diagnostic table reports `N server(s) verified, M Claude plugin(s) missing (…)` when the layer is half-installed. Severity `warn`.
|
|
23
|
+
- **`lsp-install-claude-plugins` action** in the doctor planner — `autoOk: true` (user-scope plugin install is idempotent and fully reversible via `claude plugin uninstall`). Backfills missing plugins for installs predating v3.14.0 and recovers from manual plugin uninstalls. Pairs with other actions; never short-circuits.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **LSP opt-in is now end-to-end.** Pre-v3.14.0: `features.has_lsp_layer: true` installed the binary but the integration was dormant until the user manually enabled the plugin. Post-v3.14.0: opting in installs both layers in the same step. The framework's "always-ask, never-assume" contract still applies — when `claude` is not in `tools.enabled` OR the `claude` CLI is missing, the install gracefully degrades (no error, clear hint, deferred until the prerequisites are in place). `baldart doctor` then surfaces the half-install state on every subsequent invocation.
|
|
28
|
+
|
|
29
|
+
### Notes
|
|
30
|
+
|
|
31
|
+
- **No new `baldart.config.yml` keys.** The plugin install reuses `features.has_lsp_layer`, `lsp.installed_servers`, and `tools.enabled` already in place — so the [schema-change propagation rule](../.claude/projects/-Users-antoniobaldassarre-BALDART/memory/feedback_schema_change_propagation.md) does not require a new template / configure prompt / update detector for this release. Scope is hard-coded to `user` (machine-wide LSP plugins) rather than exposed as `lsp.claude_plugin_scope` — the decision is opinionated and consistent with how the official marketplace tracks its own installs. If a future release needs per-project plugin scope, *that* would trigger the propagation rule (template + prompt + update detector + this changelog).
|
|
32
|
+
- **Marketplace and plugins live at `--scope user`, not project.** Three reasons: (1) LSP plugins are machine-wide useful — typescript-lsp installed once at user scope is reused by every TS project on the machine, not duplicated N times; (2) marketplace install at user scope means subsequent `baldart configure` runs in sibling projects skip the marketplace add entirely (idempotent); (3) project-scope plugins live under `.claude/` in the consumer repo, which would either need committing (noisy diffs) or `.gitignore` adjustments (extra protocol burden). User scope avoids all three.
|
|
33
|
+
- **Idempotency is checked twice.** Marketplace add reads `claude plugin marketplace list --json` first; plugin install reads `claude plugin list --json` first. Re-running `baldart configure` (or `baldart doctor` with the action accepted) is a no-op when everything is in place — no spurious install commands, no error noise.
|
|
34
|
+
- **Gate on `claude` in `tools.enabled`.** When the consumer has `tools.enabled: [codex]` (Codex-only), the plugin install is a silent no-op — Codex has no equivalent plugin model and Claude marketplace mutation would be off-target. Doctor diagnostics for missing plugins are similarly gated.
|
|
35
|
+
- **No framework payload changes.** All work is inside `src/` — adapters, installer, configure command, doctor command. No framework agent/skill/command/protocol module touched, so existing consumers see the new behavior the next time they run `baldart configure` or `baldart doctor` after upgrading the CLI (`npm i -g baldart@latest`).
|
|
36
|
+
|
|
37
|
+
### `.framework/` gitignore self-heal
|
|
38
|
+
|
|
39
|
+
`.framework/` is a git subtree — it MUST be tracked. If anything in the gitignore chain (project `.gitignore`, `.git/info/exclude`, global `core.excludesFile`) matches `.framework`, every subsequent `git add` on files under that directory fails with `paths are ignored by one of your .gitignore files` — and `git subtree pull` itself errors out. Pre-v3.14.0 we relied on the user noticing and removing the rule manually; v3.14.0 detects and self-heals.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **`GitUtils.checkFrameworkIgnored()` and `ensureFrameworkNotIgnored()`** in `src/utils/git.js` — `check-ignore -v --no-index .framework` to detect the offending source / line / pattern, then idempotently strip standalone `.framework` rules from the project `.gitignore` and append a one-time `!.framework` + `!.framework/**` negation block marked with `# BALDART: .framework/ is a git subtree — never ignore (auto-managed)`. Re-checks after writing — if the rule lives in a location we cannot rewrite from here (parent-dir `.gitignore` outside the repo, non-standard `core.excludesFile`), surfaces the leftover state via `{ stillIgnored: true, recheck }` so the caller can hard-fail with actionable output.
|
|
44
|
+
- **Self-heal call in `baldart add`** (before `git subtree add`), **`baldart update`** (before `git subtree pull`), **`baldart push`** (before any `git add`). All three flows degrade gracefully — if `check-ignore` itself fails, the heal step prints a warning and continues; if the rule persists after heal, the command exits 1 with the resolution path printed.
|
|
45
|
+
|
|
46
|
+
### Notes (gitignore)
|
|
47
|
+
|
|
48
|
+
- **Idempotent across re-runs.** The negation marker is checked before appending; the strip step is a `filter` over current lines. Running `add` / `update` / `push` repeatedly on a healthy repo is a no-op.
|
|
49
|
+
- **No false positives on .framework-related paths.** The strip filter matches only the six exact forms (`.framework`, `.framework/`, `/.framework`, `/.framework/`, `.framework/*`, `/.framework/*`) — `.frameworks/` or `framework/something.bak` patterns are preserved untouched.
|
|
50
|
+
|
|
8
51
|
## [3.13.0] - 2026-05-23
|
|
9
52
|
|
|
10
53
|
BALDART closes **two drift visibility gaps in one release**. (1) The CLI binary (installed globally via `npm i -g baldart`) and the framework payload (`.framework/` inside each consumer repo, updated by `baldart update`) drifted silently — users on an old CLI never learned that new flags, new doctor checks, or new adapters had shipped. (2) The framework payload on disk and the `.baldart/state.json` ledger could drift silently when the ledger write failed (the catch in `update.js` was silent until now), when an update flow exited mid-way, or when a manual commit excluded `.baldart/state.json` — `baldart doctor` had no way to detect or fix this. Both gaps are now closed: a **non-invasive CLI self-update notifier** for the first, and **ledger drift detection + self-heal** in the doctor for the second. The unifying theme is "no silent drift" — every layer that can fall out of sync now reports it and offers a one-step fix.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.14.0
|
|
@@ -45,7 +45,7 @@ so existing behavior is preserved).
|
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
-
## 2. The
|
|
48
|
+
## 2. The five moving parts
|
|
49
49
|
|
|
50
50
|
```
|
|
51
51
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
@@ -53,17 +53,30 @@ so existing behavior is preserved).
|
|
|
53
53
|
│ features.has_lsp_layer: true │
|
|
54
54
|
│ lsp.installed_servers: [typescript, python] │
|
|
55
55
|
│ lsp.auto_verify: true │
|
|
56
|
+
│ tools.enabled: [claude] ← gates native plugin install │
|
|
56
57
|
└──────────────┬──────────────────────────────────────────────────────┘
|
|
57
58
|
│ read by
|
|
58
59
|
▼
|
|
59
60
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
60
61
|
│ Plumbing layer (CLI, this repo) │
|
|
61
62
|
│ src/utils/lsp-adapters/<lang>.js ← per-language recipes │
|
|
62
|
-
│ src/utils/lsp-installer.js ←
|
|
63
|
+
│ src/utils/lsp-installer.js ← server + plugin installer │
|
|
63
64
|
│ src/commands/configure.js (prompt + install on opt-in) │
|
|
64
|
-
│ src/commands/doctor.js (verify + lsp-fix
|
|
65
|
+
│ src/commands/doctor.js (verify + lsp-fix + plugin-fix) │
|
|
65
66
|
└──────────────┬──────────────────────────────────────────────────────┘
|
|
66
|
-
│ writes installed_servers;
|
|
67
|
+
│ writes installed_servers; runs `claude plugin install`
|
|
68
|
+
▼
|
|
69
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
70
|
+
│ Native Claude Code integration (since v3.14.0) │
|
|
71
|
+
│ Marketplace: anthropics/claude-plugins-official (user scope) │
|
|
72
|
+
│ Plugins: │
|
|
73
|
+
│ typescript-lsp@claude-plugins-official │
|
|
74
|
+
│ pyright-lsp@claude-plugins-official │
|
|
75
|
+
│ gopls-lsp@claude-plugins-official │
|
|
76
|
+
│ rust-analyzer-lsp@claude-plugins-official │
|
|
77
|
+
│ ruby-lsp@claude-plugins-official │
|
|
78
|
+
└──────────────┬──────────────────────────────────────────────────────┘
|
|
79
|
+
│ surfaces LSP to Claude Code at runtime
|
|
67
80
|
▼
|
|
68
81
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
69
82
|
│ Protocol layer (framework/) │
|
|
@@ -85,9 +98,13 @@ so existing behavior is preserved).
|
|
|
85
98
|
```
|
|
86
99
|
|
|
87
100
|
Each layer is independently testable and individually replaceable. The
|
|
88
|
-
adapters
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
adapters describe *how to install* the language server AND *which native
|
|
102
|
+
plugin* surfaces it to Claude. Pre-v3.14.0 the second half was a manual
|
|
103
|
+
step the user almost always skipped — opting into LSP put a binary on
|
|
104
|
+
disk that Claude never invoked. v3.14.0 closes that gap: the configure
|
|
105
|
+
flow installs both halves in one step, gated on `claude` being in
|
|
106
|
+
`tools.enabled` (Codex-only consumers are unaffected — the gate degrades
|
|
107
|
+
to a silent no-op).
|
|
91
108
|
|
|
92
109
|
---
|
|
93
110
|
|
|
@@ -123,6 +140,23 @@ When the user confirms, for each detected language:
|
|
|
123
140
|
`baldart doctor` re-verifies on the next run and flags the mismatch if the
|
|
124
141
|
user hasn't run the printed command yet.
|
|
125
142
|
|
|
143
|
+
**Then (v3.14.0+) the native plugin install runs.** For every server in
|
|
144
|
+
`installed_servers` whose adapter declares a `claudePluginId()`, configure:
|
|
145
|
+
|
|
146
|
+
1. Verifies the `claude` CLI is on PATH (skipped silently if not — manual
|
|
147
|
+
install commands are printed instead).
|
|
148
|
+
2. Idempotently registers the `claude-plugins-official` marketplace at user
|
|
149
|
+
scope (`claude plugin marketplace add anthropics/claude-plugins-official
|
|
150
|
+
--scope user` — no-op when already registered).
|
|
151
|
+
3. Reads `claude plugin list --json` to skip plugins already installed at
|
|
152
|
+
any scope.
|
|
153
|
+
4. Runs `claude plugin install <id> --scope user` for each missing plugin
|
|
154
|
+
(e.g. `typescript-lsp@claude-plugins-official`). User scope = a single
|
|
155
|
+
install is reused by every project on the machine.
|
|
156
|
+
|
|
157
|
+
Gated on `claude` being in `tools.enabled` — Codex-only consumers see a
|
|
158
|
+
silent skip, not a failure.
|
|
159
|
+
|
|
126
160
|
### 3.2 Update from pre-v3.10
|
|
127
161
|
|
|
128
162
|
```bash
|
|
@@ -238,7 +272,7 @@ Every adapter is a class exporting:
|
|
|
238
272
|
| `npmPackage` | string (npm-dev only) | What goes after `npm install --save-dev` |
|
|
239
273
|
| `installCommand()` | string | The exact shell command (printed or executed) |
|
|
240
274
|
| `verifyCommand()` | string | A no-side-effect probe (e.g. `--version`) |
|
|
241
|
-
| `claudePluginId()` | string \| null |
|
|
275
|
+
| `claudePluginId()` | string \| null | Native Claude Code plugin id (e.g. `typescript-lsp@claude-plugins-official`) — installed by the configure flow alongside the language server (v3.14.0+). `null` = no companion plugin |
|
|
242
276
|
| `static detect(cwd)` | boolean | Pure: does the language live in this repo? |
|
|
243
277
|
|
|
244
278
|
The contract is enforced by convention, not by an interface check. When you
|
|
@@ -249,14 +283,37 @@ to the REGISTRY in `lsp-adapters/index.js`.
|
|
|
249
283
|
|
|
250
284
|
`LspInstaller` exposes:
|
|
251
285
|
|
|
286
|
+
**Language-server layer (since v3.10.0):**
|
|
287
|
+
|
|
252
288
|
- `detectLanguages()` → string[] — pure, no I/O beyond filesystem reads
|
|
253
289
|
- `recommend(langs?)` → entries with install metadata for UI consumption
|
|
254
290
|
- `installServers(names, { interactive=true, dryRun=false })` →
|
|
255
291
|
`{ installed, skipped, manual }` — returns rather than throws
|
|
256
292
|
- `verifyServers(names)` → `[{ name, ok, output|error }]` — never throws
|
|
257
293
|
|
|
294
|
+
**Native Claude Code plugin layer (since v3.14.0):**
|
|
295
|
+
|
|
296
|
+
- `isClaudeCliAvailable()` → boolean — `claude --version` probe with 5s timeout
|
|
297
|
+
- `ensureClaudeMarketplace()` → `{ ok, alreadyPresent, error? }` — idempotently
|
|
298
|
+
registers `anthropics/claude-plugins-official` at user scope; reads the
|
|
299
|
+
existing marketplace list first so re-runs are no-ops
|
|
300
|
+
- `listInstalledClaudePlugins()` → `Set<string>` of `<name>@<marketplace>` ids
|
|
301
|
+
pulled from `claude plugin list --json`; empty set on any failure (degrade
|
|
302
|
+
open — we'd rather attempt a redundant install than incorrectly skip)
|
|
303
|
+
- `installClaudePlugins(names, { toolsEnabled=['claude'], scope='user', interactive=false })`
|
|
304
|
+
→ `{ installed, skipped, manual, marketplace, reason? }` — gated on
|
|
305
|
+
`claude` being in `toolsEnabled` AND the CLI being available; per-plugin
|
|
306
|
+
idempotency check; defaults to `--scope user` so a single install is reused
|
|
307
|
+
across every project on the machine
|
|
308
|
+
- `verifyClaudePlugins(names)` → `[{ name, pluginId, installed }]` —
|
|
309
|
+
consumed by `baldart doctor` to surface the half-installed state where the
|
|
310
|
+
language-server binary is on PATH but the matching plugin is missing
|
|
311
|
+
|
|
258
312
|
All side-effecting methods are gated by `interactive` and a per-server
|
|
259
|
-
confirm; doctor passes `interactive: true`
|
|
313
|
+
confirm; doctor passes `interactive: true` for the server layer and
|
|
314
|
+
`interactive: false` for the plugin layer (the plugin install is idempotent
|
|
315
|
+
and user-scope, so the outer doctor prompt + per-action `autoOk: true` is
|
|
316
|
+
sufficient).
|
|
260
317
|
|
|
261
318
|
---
|
|
262
319
|
|
package/package.json
CHANGED
package/src/commands/add.js
CHANGED
|
@@ -79,6 +79,30 @@ async function add(repo, options) {
|
|
|
79
79
|
// Step 4: Download framework
|
|
80
80
|
UI.header('STEP 4/5: Download Framework');
|
|
81
81
|
|
|
82
|
+
// `.framework/` MUST be trackable — `git subtree add` itself fails if a
|
|
83
|
+
// pre-existing `.gitignore` rule matches the path. Heal proactively.
|
|
84
|
+
// (since v3.14.0)
|
|
85
|
+
try {
|
|
86
|
+
const heal = await git.ensureFrameworkNotIgnored();
|
|
87
|
+
if (heal.wasFixed) {
|
|
88
|
+
UI.warning(`.framework/ was ignored by ${heal.source || 'a gitignore'} — auto-healed before subtree add.`);
|
|
89
|
+
if (heal.removedLines > 0) {
|
|
90
|
+
UI.info(` Removed ${heal.removedLines} stale .framework line(s) from .gitignore.`);
|
|
91
|
+
}
|
|
92
|
+
if (heal.appendedNegation) {
|
|
93
|
+
UI.info(' Appended `!.framework` negation block to .gitignore (idempotent).');
|
|
94
|
+
}
|
|
95
|
+
if (heal.stillIgnored) {
|
|
96
|
+
UI.error('.framework/ is STILL ignored after auto-heal — install would fail.');
|
|
97
|
+
UI.info(`Source: ${heal.recheck.source}:${heal.recheck.line} (${heal.recheck.pattern})`);
|
|
98
|
+
UI.info('Resolve by removing that rule, then re-run `baldart add`.');
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
UI.warning(`Could not check .gitignore for .framework: ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
const downloadSpinner = UI.spinner(`Downloading from ${repo}...`).start();
|
|
83
107
|
|
|
84
108
|
try {
|
|
@@ -607,6 +607,50 @@ async function interactivePrompts(merged, detected) {
|
|
|
607
607
|
UI.warning(`Skipped: ${skipped.map(s => `${s.name} (${s.reason})`).join(', ')}`);
|
|
608
608
|
}
|
|
609
609
|
}
|
|
610
|
+
|
|
611
|
+
// ---- Native Claude Code companion plugins (since v3.14.0) ----------
|
|
612
|
+
// Install the official LSP plugin alongside each language server. Without
|
|
613
|
+
// this step, opting into the LSP layer only puts the binary on disk —
|
|
614
|
+
// Claude itself never sees it and agents fall back to Grep silently.
|
|
615
|
+
// Gated on `claude` being in `tools.enabled` and the `claude` CLI being
|
|
616
|
+
// on PATH; degrades gracefully otherwise.
|
|
617
|
+
const allLspServers = Array.from(new Set([
|
|
618
|
+
...(merged.lsp.installed_servers || [])
|
|
619
|
+
]));
|
|
620
|
+
if (allLspServers.length) {
|
|
621
|
+
const pluginResult = await installer.installClaudePlugins(allLspServers, {
|
|
622
|
+
toolsEnabled: merged.tools.enabled,
|
|
623
|
+
interactive: false,
|
|
624
|
+
scope: 'user'
|
|
625
|
+
});
|
|
626
|
+
if (pluginResult.reason === 'claude not in tools.enabled') {
|
|
627
|
+
UI.info('Skipping native Claude plugin install (claude not in tools.enabled).');
|
|
628
|
+
} else if (pluginResult.reason === 'claude CLI not on PATH') {
|
|
629
|
+
UI.warning('`claude` CLI not on PATH — native LSP plugins not installed.');
|
|
630
|
+
UI.info('Install them manually after adding the CLI:');
|
|
631
|
+
for (const m of pluginResult.skipped) {
|
|
632
|
+
// We don't have manual[] entries here (no targets resolved past the
|
|
633
|
+
// gate), so synthesize the command from the adapter directly.
|
|
634
|
+
try {
|
|
635
|
+
const a = lspAdapters.getAdapter(m.name, process.cwd());
|
|
636
|
+
const pid = a.claudePluginId();
|
|
637
|
+
if (pid) UI.code(`claude plugin install ${pid} --scope user`, m.name);
|
|
638
|
+
} catch { /* unknown adapter */ }
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
if (pluginResult.installed.length) {
|
|
642
|
+
UI.success(`Installed Claude LSP plugin(s): ${pluginResult.installed.map(i => i.pluginId).join(', ')}`);
|
|
643
|
+
}
|
|
644
|
+
const alreadyOnes = pluginResult.skipped.filter(s => s.reason === 'plugin already installed');
|
|
645
|
+
if (alreadyOnes.length) {
|
|
646
|
+
UI.info(`Already installed: ${alreadyOnes.map(s => s.name).join(', ')}`);
|
|
647
|
+
}
|
|
648
|
+
for (const m of pluginResult.manual) {
|
|
649
|
+
UI.warning(`Could not auto-install ${m.label} plugin — run manually:`);
|
|
650
|
+
UI.code(m.command, '');
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
610
654
|
}
|
|
611
655
|
} else {
|
|
612
656
|
// Feature disabled — wipe the state so `installed_servers` doesn't lie.
|
package/src/commands/doctor.js
CHANGED
|
@@ -215,10 +215,18 @@ async function detectState(cwd, opts = {}) {
|
|
|
215
215
|
state.editGateRegistered = false;
|
|
216
216
|
try { state.editGateRegistered = Hooks.isRegistered(cwd); } catch (_) {}
|
|
217
217
|
|
|
218
|
+
// `.framework/` is a git subtree — gitignore matching it breaks every
|
|
219
|
+
// future `git add` under that path (the failure mode that hit v3.13.0
|
|
220
|
+
// consumers during push). Detect any gitignore rule that catches the
|
|
221
|
+
// path so the planner can heal it before any flow needs to stage files.
|
|
222
|
+
state.frameworkIgnored = { ignored: false };
|
|
223
|
+
try { state.frameworkIgnored = await git.checkFrameworkIgnored(); } catch (_) {}
|
|
224
|
+
|
|
218
225
|
// ---- LSP layer (since v3.10.0) -------------------------------------
|
|
219
226
|
state.lspEnabled = false;
|
|
220
227
|
state.lspInstalled = [];
|
|
221
228
|
state.lspBroken = [];
|
|
229
|
+
state.lspMissingClaudePlugins = []; // [{ name, pluginId }] — since v3.14.0
|
|
222
230
|
try {
|
|
223
231
|
// Reuse the `config` already parsed above instead of re-reading the file.
|
|
224
232
|
if (config && !config.__malformed && config.features && config.features.has_lsp_layer === true) {
|
|
@@ -230,6 +238,20 @@ async function detectState(cwd, opts = {}) {
|
|
|
230
238
|
const installer = new LspInstaller(cwd);
|
|
231
239
|
const results = installer.verifyServers(installed);
|
|
232
240
|
state.lspBroken = results.filter(r => !r.ok).map(r => r.name);
|
|
241
|
+
|
|
242
|
+
// Companion Claude plugin check (v3.14.0+). Gated on `claude` being
|
|
243
|
+
// in tools.enabled AND the `claude` CLI being available; otherwise
|
|
244
|
+
// we don't report missing plugins (manual install hint would be
|
|
245
|
+
// misleading without the CLI).
|
|
246
|
+
const toolsEnabled = (config.tools && Array.isArray(config.tools.enabled))
|
|
247
|
+
? config.tools.enabled
|
|
248
|
+
: ['claude'];
|
|
249
|
+
if (toolsEnabled.includes('claude') && installer.isClaudeCliAvailable()) {
|
|
250
|
+
const checks = installer.verifyClaudePlugins(installed);
|
|
251
|
+
state.lspMissingClaudePlugins = checks
|
|
252
|
+
.filter(c => c.installed === false)
|
|
253
|
+
.map(c => ({ name: c.name, pluginId: c.pluginId }));
|
|
254
|
+
}
|
|
233
255
|
}
|
|
234
256
|
}
|
|
235
257
|
} catch (_) { /* never block doctor on LSP probe */ }
|
|
@@ -352,6 +374,42 @@ function planActions(state) {
|
|
|
352
374
|
// Drift is non-blocking — keep checking for other actions after.
|
|
353
375
|
}
|
|
354
376
|
|
|
377
|
+
// Heal `.gitignore` BEFORE any other action that may stage files under
|
|
378
|
+
// `.framework/`. Without this, every downstream `git add` (push, auto-commit
|
|
379
|
+
// during update, lsp-install commits, …) would fail with "paths are ignored
|
|
380
|
+
// by one of your .gitignore files". (since v3.14.0)
|
|
381
|
+
if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
|
|
382
|
+
actions.push({
|
|
383
|
+
key: 'unignore-framework',
|
|
384
|
+
label: 'Heal .gitignore so .framework/ is trackable',
|
|
385
|
+
why: `.framework/ is a git subtree managed by BALDART — it MUST be tracked. Currently ignored by ${state.frameworkIgnored.source}:${state.frameworkIgnored.line} (pattern: ${state.frameworkIgnored.pattern}). Any subsequent \`git add\` under that path would fail.`,
|
|
386
|
+
autoOk: true, // pure local file rewrite, idempotent, fully reversible
|
|
387
|
+
run: async () => {
|
|
388
|
+
const git = new GitUtils(state.cwd);
|
|
389
|
+
const heal = await git.ensureFrameworkNotIgnored();
|
|
390
|
+
if (!heal.wasFixed) {
|
|
391
|
+
UI.info('.gitignore already healthy — nothing to do.');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
UI.success('.gitignore healed:');
|
|
395
|
+
if (heal.removedLines > 0) {
|
|
396
|
+
UI.info(` Removed ${heal.removedLines} stale .framework line(s).`);
|
|
397
|
+
}
|
|
398
|
+
if (heal.appendedNegation) {
|
|
399
|
+
UI.info(' Appended `!.framework` negation block (idempotent).');
|
|
400
|
+
}
|
|
401
|
+
if (heal.stillIgnored) {
|
|
402
|
+
UI.warning(' .framework/ is STILL ignored — likely a parent-dir .gitignore outside this repo.');
|
|
403
|
+
UI.info(` Source: ${heal.recheck.source}:${heal.recheck.line} (${heal.recheck.pattern})`);
|
|
404
|
+
UI.info(' Resolve by removing that rule manually.');
|
|
405
|
+
} else {
|
|
406
|
+
UI.info(' Commit the .gitignore change when you next push.');
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
// Non-blocking — keep looking for other actions to run in the same pass.
|
|
411
|
+
}
|
|
412
|
+
|
|
355
413
|
if (state.editGateRegistered === false) {
|
|
356
414
|
actions.push({
|
|
357
415
|
key: 'register-edit-gate',
|
|
@@ -386,6 +444,40 @@ function planActions(state) {
|
|
|
386
444
|
});
|
|
387
445
|
}
|
|
388
446
|
|
|
447
|
+
// Native Claude LSP plugins (v3.14.0+). The server binary alone is not
|
|
448
|
+
// enough — without the matching `<lang>-lsp@claude-plugins-official` plugin
|
|
449
|
+
// installed, Claude Code never invokes the LSP and agents silently fall back
|
|
450
|
+
// to Grep. This action backfills the plugins for installs predating v3.14.0
|
|
451
|
+
// and recovers from manual plugin uninstalls.
|
|
452
|
+
if (state.lspEnabled && state.lspMissingClaudePlugins && state.lspMissingClaudePlugins.length > 0) {
|
|
453
|
+
const missing = state.lspMissingClaudePlugins;
|
|
454
|
+
actions.push({
|
|
455
|
+
key: 'lsp-install-claude-plugins',
|
|
456
|
+
label: `Install ${missing.length} native Claude LSP plugin(s)`,
|
|
457
|
+
why: `LSP layer is enabled and the language server(s) are present, but the matching Claude Code plugin(s) are not installed: ${missing.map(m => m.pluginId).join(', ')}. Without them Claude never sees the LSP and the layer is effectively dead. Standard since v3.14.0; missing on installs predating that version.`,
|
|
458
|
+
autoOk: true, // user-scope plugin install, idempotent, fully reversible via `claude plugin uninstall`
|
|
459
|
+
run: async () => {
|
|
460
|
+
const installer = new LspInstaller(state.cwd);
|
|
461
|
+
const res = await installer.installClaudePlugins(missing.map(m => m.name), {
|
|
462
|
+
toolsEnabled: ['claude'], // we already checked tools.enabled in detectState
|
|
463
|
+
interactive: false,
|
|
464
|
+
scope: 'user',
|
|
465
|
+
});
|
|
466
|
+
if (res.installed.length) {
|
|
467
|
+
UI.success(`Installed: ${res.installed.map(i => i.pluginId).join(', ')}`);
|
|
468
|
+
}
|
|
469
|
+
for (const m of res.manual || []) {
|
|
470
|
+
UI.warning(`Could not auto-install ${m.label}:`);
|
|
471
|
+
console.log(` ${m.command}`);
|
|
472
|
+
}
|
|
473
|
+
if (res.skipped.length) {
|
|
474
|
+
const real = res.skipped.filter(s => s.reason !== 'plugin already installed');
|
|
475
|
+
if (real.length) UI.warning(`Skipped: ${real.map(s => `${s.name} (${s.reason})`).join(', ')}`);
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
389
481
|
const remoteAhead = state.remote.fetched && state.remote.behind > 0;
|
|
390
482
|
const hasLocalWork = state.workingTreeDirty > 0 || state.commitsAhead > 0;
|
|
391
483
|
|
|
@@ -530,15 +622,29 @@ function renderDiagnostic(state) {
|
|
|
530
622
|
state.editGateRegistered ? 'ok' : 'warn'
|
|
531
623
|
));
|
|
532
624
|
|
|
625
|
+
if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
|
|
626
|
+
console.log(statusLine(
|
|
627
|
+
'.gitignore',
|
|
628
|
+
`IGNORES .framework/ (${state.frameworkIgnored.source}:${state.frameworkIgnored.line}) — will be auto-healed`,
|
|
629
|
+
'warn'
|
|
630
|
+
));
|
|
631
|
+
}
|
|
632
|
+
|
|
533
633
|
if (state.lspEnabled) {
|
|
534
634
|
const total = state.lspInstalled.length;
|
|
535
635
|
const broken = state.lspBroken.length;
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
636
|
+
const missingPlugins = (state.lspMissingClaudePlugins || []).length;
|
|
637
|
+
let value;
|
|
638
|
+
if (total === 0) {
|
|
639
|
+
value = 'enabled but no servers installed';
|
|
640
|
+
} else if (broken > 0) {
|
|
641
|
+
value = `${broken}/${total} broken (${state.lspBroken.join(', ')})`;
|
|
642
|
+
} else if (missingPlugins > 0) {
|
|
643
|
+
value = `${total} server(s) verified, ${missingPlugins} Claude plugin(s) missing (${state.lspMissingClaudePlugins.map(m => m.pluginId).join(', ')})`;
|
|
644
|
+
} else {
|
|
645
|
+
value = `${total} server(s) verified (${state.lspInstalled.join(', ')})`;
|
|
646
|
+
}
|
|
647
|
+
const severity = total === 0 || broken > 0 || missingPlugins > 0 ? 'warn' : 'ok';
|
|
542
648
|
console.log(statusLine('LSP layer', value, severity));
|
|
543
649
|
} else {
|
|
544
650
|
console.log(statusLine('LSP layer', 'disabled', 'ok'));
|
package/src/commands/push.js
CHANGED
|
Binary file
|
package/src/commands/update.js
CHANGED
|
@@ -130,6 +130,29 @@ async function update(options = {}) {
|
|
|
130
130
|
const currentVersion = await git.getFrameworkVersion();
|
|
131
131
|
UI.success(`Current version: ${currentVersion}`);
|
|
132
132
|
|
|
133
|
+
// `.framework/` MUST be trackable — heal a stale `.gitignore` before
|
|
134
|
+
// any `git subtree pull` / `git add` runs. Idempotent. (since v3.14.0)
|
|
135
|
+
try {
|
|
136
|
+
const heal = await git.ensureFrameworkNotIgnored();
|
|
137
|
+
if (heal.wasFixed) {
|
|
138
|
+
UI.warning(`.framework/ was ignored by ${heal.source || 'a gitignore'} — auto-healed.`);
|
|
139
|
+
if (heal.removedLines > 0) {
|
|
140
|
+
UI.info(` Removed ${heal.removedLines} stale .framework line(s) from .gitignore.`);
|
|
141
|
+
}
|
|
142
|
+
if (heal.appendedNegation) {
|
|
143
|
+
UI.info(' Appended `!.framework` negation block to .gitignore (idempotent).');
|
|
144
|
+
}
|
|
145
|
+
if (heal.stillIgnored) {
|
|
146
|
+
UI.error('.framework/ is STILL ignored after auto-heal — likely a parent-dir .gitignore outside this repo.');
|
|
147
|
+
UI.info(`Source: ${heal.recheck.source}:${heal.recheck.line} (${heal.recheck.pattern})`);
|
|
148
|
+
UI.info('Resolve by removing that rule, then re-run `baldart update`.');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
UI.warning(`Could not check .gitignore for .framework: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
133
156
|
// Step 2: Check for updates
|
|
134
157
|
UI.header('STEP 2/5: Check for Updates');
|
|
135
158
|
|
package/src/utils/git.js
CHANGED
|
@@ -176,6 +176,99 @@ class GitUtils {
|
|
|
176
176
|
return '';
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
// ────────────────────────────────────────────────────────────────────
|
|
181
|
+
// `.framework/` is a git subtree — it MUST be tracked. If anything in
|
|
182
|
+
// the gitignore chain (project .gitignore, .git/info/exclude, global
|
|
183
|
+
// excludes) matches `.framework`, every subsequent `git add` on files
|
|
184
|
+
// under that directory fails with "paths are ignored by one of your
|
|
185
|
+
// .gitignore files". Detect + self-heal idempotently so install /
|
|
186
|
+
// update / push flows never trip over this. (since v3.14.0)
|
|
187
|
+
// ────────────────────────────────────────────────────────────────────
|
|
188
|
+
async checkFrameworkIgnored() {
|
|
189
|
+
try {
|
|
190
|
+
const out = await this.git.raw([
|
|
191
|
+
'check-ignore',
|
|
192
|
+
'-v',
|
|
193
|
+
'--no-index',
|
|
194
|
+
'--',
|
|
195
|
+
FRAMEWORK_DIR
|
|
196
|
+
]);
|
|
197
|
+
const line = (out || '').trim();
|
|
198
|
+
if (!line) return { ignored: false };
|
|
199
|
+
// Format: "<source>:<line>:<pattern>\t<path>"
|
|
200
|
+
const m = /^(.+?):(\d+):([^\t]+)\t/.exec(line);
|
|
201
|
+
return {
|
|
202
|
+
ignored: true,
|
|
203
|
+
source: m ? m[1] : 'unknown',
|
|
204
|
+
line: m ? Number(m[2]) : null,
|
|
205
|
+
pattern: m ? m[3] : null,
|
|
206
|
+
raw: line
|
|
207
|
+
};
|
|
208
|
+
} catch (_) {
|
|
209
|
+
// `git check-ignore` exits 1 when the path is not ignored — simple-git
|
|
210
|
+
// throws on non-zero. Treat as "not ignored".
|
|
211
|
+
return { ignored: false };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async ensureFrameworkNotIgnored() {
|
|
216
|
+
const status = await this.checkFrameworkIgnored();
|
|
217
|
+
if (!status.ignored) return { wasFixed: false };
|
|
218
|
+
|
|
219
|
+
const gitignorePath = path.join(this.cwd, '.gitignore');
|
|
220
|
+
const marker = '# BALDART: .framework/ is a git subtree — never ignore (auto-managed)';
|
|
221
|
+
const negationBlock = `\n${marker}\n!.framework\n!.framework/**\n`;
|
|
222
|
+
|
|
223
|
+
let original = '';
|
|
224
|
+
if (fs.existsSync(gitignorePath)) {
|
|
225
|
+
original = fs.readFileSync(gitignorePath, 'utf8');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 1. Strip standalone .framework rules from the project .gitignore so we
|
|
229
|
+
// don't leave stale "do ignore" lines that future maintainers might
|
|
230
|
+
// notice and try to "fix" by removing our negation.
|
|
231
|
+
const lines = original.split('\n');
|
|
232
|
+
const stripped = lines.filter((line) => {
|
|
233
|
+
const t = line.trim();
|
|
234
|
+
return !(
|
|
235
|
+
t === '.framework' ||
|
|
236
|
+
t === '.framework/' ||
|
|
237
|
+
t === '/.framework' ||
|
|
238
|
+
t === '/.framework/' ||
|
|
239
|
+
t === '.framework/*' ||
|
|
240
|
+
t === '/.framework/*'
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
const removedLines = lines.length - stripped.length;
|
|
244
|
+
|
|
245
|
+
// 2. Append the negation block (only once — idempotent across re-runs).
|
|
246
|
+
let next = stripped.join('\n');
|
|
247
|
+
if (!next.includes(marker)) {
|
|
248
|
+
if (next.length && !next.endsWith('\n')) next += '\n';
|
|
249
|
+
next += negationBlock;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (next !== original) {
|
|
253
|
+
fs.writeFileSync(gitignorePath, next, 'utf8');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 3. Verify the heal actually took effect. If the rule lives somewhere we
|
|
257
|
+
// cannot rewrite from here (a parent-dir .gitignore outside the repo,
|
|
258
|
+
// a non-standard core.excludesFile that overrides the project file),
|
|
259
|
+
// surface the leftover state to the caller instead of pretending.
|
|
260
|
+
const recheck = await this.checkFrameworkIgnored();
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
wasFixed: true,
|
|
264
|
+
stillIgnored: recheck.ignored,
|
|
265
|
+
removedLines,
|
|
266
|
+
appendedNegation: !original.includes(marker),
|
|
267
|
+
source: status.source,
|
|
268
|
+
pattern: status.pattern,
|
|
269
|
+
recheck
|
|
270
|
+
};
|
|
271
|
+
}
|
|
179
272
|
}
|
|
180
273
|
|
|
181
274
|
module.exports = GitUtils;
|
|
@@ -19,7 +19,7 @@ class GoAdapter {
|
|
|
19
19
|
|
|
20
20
|
installCommand() { return 'go install golang.org/x/tools/gopls@latest'; }
|
|
21
21
|
verifyCommand() { return 'gopls version'; }
|
|
22
|
-
claudePluginId() { return
|
|
22
|
+
claudePluginId() { return 'gopls-lsp@claude-plugins-official'; }
|
|
23
23
|
|
|
24
24
|
static detect(cwd = process.cwd()) {
|
|
25
25
|
return fs.existsSync(path.join(cwd, 'go.mod'));
|
|
@@ -23,7 +23,7 @@ class PythonAdapter {
|
|
|
23
23
|
|
|
24
24
|
installCommand() { return 'npm install --save-dev pyright'; }
|
|
25
25
|
verifyCommand() { return 'npx --no-install pyright --version'; }
|
|
26
|
-
claudePluginId() { return
|
|
26
|
+
claudePluginId() { return 'pyright-lsp@claude-plugins-official'; }
|
|
27
27
|
|
|
28
28
|
static detect(cwd = process.cwd()) {
|
|
29
29
|
const markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile'];
|
|
@@ -19,7 +19,7 @@ class RubyAdapter {
|
|
|
19
19
|
|
|
20
20
|
installCommand() { return 'gem install ruby-lsp'; }
|
|
21
21
|
verifyCommand() { return 'ruby-lsp --version'; }
|
|
22
|
-
claudePluginId() { return
|
|
22
|
+
claudePluginId() { return 'ruby-lsp@claude-plugins-official'; }
|
|
23
23
|
|
|
24
24
|
static detect(cwd = process.cwd()) {
|
|
25
25
|
const markers = ['Gemfile', '.ruby-version', 'config.ru'];
|
|
@@ -16,7 +16,7 @@ class RustAdapter {
|
|
|
16
16
|
|
|
17
17
|
installCommand() { return 'rustup component add rust-analyzer'; }
|
|
18
18
|
verifyCommand() { return 'rust-analyzer --version'; }
|
|
19
|
-
claudePluginId() { return
|
|
19
|
+
claudePluginId() { return 'rust-analyzer-lsp@claude-plugins-official'; }
|
|
20
20
|
|
|
21
21
|
static detect(cwd = process.cwd()) {
|
|
22
22
|
return fs.existsSync(path.join(cwd, 'Cargo.toml'));
|
|
@@ -32,8 +32,14 @@ class TypeScriptAdapter {
|
|
|
32
32
|
/** Verify the binary is reachable (no execution side effects). */
|
|
33
33
|
verifyCommand() { return 'npx --no-install typescript-language-server --version'; }
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Claude Code code-intelligence plugin id, if any. Null = no companion plugin.
|
|
37
|
+
* When non-null, `LspInstaller.installClaudePlugins()` installs this plugin
|
|
38
|
+
* from the `claude-plugins-official` marketplace (anthropics/claude-plugins-official)
|
|
39
|
+
* so the LSP layer is wired into Claude Code natively instead of leaving the
|
|
40
|
+
* user to enable it manually after `baldart configure`.
|
|
41
|
+
*/
|
|
42
|
+
claudePluginId() { return 'typescript-lsp@claude-plugins-official'; }
|
|
37
43
|
|
|
38
44
|
/** Autodetect whether this language is in scope for the project. */
|
|
39
45
|
static detect(cwd = process.cwd()) {
|
|
@@ -1,7 +1,42 @@
|
|
|
1
1
|
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
2
5
|
const { detectAll, getAdapter, listAdapters } = require('./lsp-adapters');
|
|
3
6
|
const UI = require('./ui');
|
|
4
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The `claude` CLI is built on Ink (React for TUIs) and writes stdout in a
|
|
10
|
+
* way that truncates at ~8 KB when piped directly to a node child process —
|
|
11
|
+
* regardless of `maxBuffer` setting. Workaround: shell-redirect to a temp
|
|
12
|
+
* file and read the file back. Used only for read-only `--json` queries.
|
|
13
|
+
*
|
|
14
|
+
* Returns the file contents as a string, or throws (caller catches).
|
|
15
|
+
*/
|
|
16
|
+
function captureClaudeJson(args, { timeout = 10000 } = {}) {
|
|
17
|
+
const tmp = path.join(os.tmpdir(), `baldart-claude-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
18
|
+
try {
|
|
19
|
+
execSync(`claude ${args} > ${JSON.stringify(tmp)} 2>/dev/null`, {
|
|
20
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
21
|
+
timeout,
|
|
22
|
+
shell: '/bin/bash',
|
|
23
|
+
});
|
|
24
|
+
return fs.readFileSync(tmp, 'utf8');
|
|
25
|
+
} finally {
|
|
26
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* best-effort cleanup */ }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Anthropic's official marketplace for native Claude Code LSP plugins
|
|
32
|
+
* (typescript-lsp, pyright-lsp, gopls-lsp, rust-analyzer-lsp, ruby-lsp, …).
|
|
33
|
+
* Every adapter's `claudePluginId()` returns an id namespaced under this
|
|
34
|
+
* marketplace, so the installer hard-codes it here. If a future adapter needs
|
|
35
|
+
* a different marketplace, lift this into a per-adapter field.
|
|
36
|
+
*/
|
|
37
|
+
const OFFICIAL_MARKETPLACE_NAME = 'claude-plugins-official';
|
|
38
|
+
const OFFICIAL_MARKETPLACE_SOURCE = 'anthropics/claude-plugins-official';
|
|
39
|
+
|
|
5
40
|
/**
|
|
6
41
|
* High-level LSP installer used by `baldart configure` and the `lsp-bootstrap`
|
|
7
42
|
* skill / doctor step. Owns the install + verify side effects so the command
|
|
@@ -11,6 +46,10 @@ const UI = require('./ui');
|
|
|
11
46
|
* - Pure detection (no install) when only `detectLanguages` is called.
|
|
12
47
|
* - All mutating ops respect `--non-interactive` via the `interactive` flag.
|
|
13
48
|
* - Returns plain objects (no throws) so the caller can render results.
|
|
49
|
+
* - Claude plugin install ops gate on the `claude` CLI being on PATH AND on
|
|
50
|
+
* `claude` being present in `tools.enabled`; when either fails the methods
|
|
51
|
+
* report `{ skipped: [...], reason }` so the caller can render a hint
|
|
52
|
+
* instead of erroring.
|
|
14
53
|
*/
|
|
15
54
|
class LspInstaller {
|
|
16
55
|
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
@@ -91,6 +130,210 @@ class LspInstaller {
|
|
|
91
130
|
return { installed, skipped, manual };
|
|
92
131
|
}
|
|
93
132
|
|
|
133
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
134
|
+
// Native Claude Code companion plugins (since v3.14.0)
|
|
135
|
+
//
|
|
136
|
+
// For each language whose adapter declares a `claudePluginId()`, this layer
|
|
137
|
+
// ensures the matching plugin from the `claude-plugins-official` marketplace
|
|
138
|
+
// is installed. Without it, enabling `features.has_lsp_layer: true` only
|
|
139
|
+
// installs the language-server binary — Claude Code itself never sees it,
|
|
140
|
+
// and agents fall back to Grep silently. Wiring the two together is the
|
|
141
|
+
// whole point of opting into LSP, so the installer treats this as part of
|
|
142
|
+
// the install, not an optional extra.
|
|
143
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** True iff the `claude` CLI is on PATH and answers `--version`. */
|
|
146
|
+
isClaudeCliAvailable() {
|
|
147
|
+
try {
|
|
148
|
+
execSync('claude --version', { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
|
|
149
|
+
return true;
|
|
150
|
+
} catch { return false; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Idempotently register the official marketplace. Reads the existing list
|
|
155
|
+
* first so we never spam `claude plugin marketplace add` on every configure
|
|
156
|
+
* run. Returns `{ ok, alreadyPresent, error? }`. Never throws.
|
|
157
|
+
*/
|
|
158
|
+
ensureClaudeMarketplace() {
|
|
159
|
+
if (!this.isClaudeCliAvailable()) {
|
|
160
|
+
return { ok: false, error: 'claude CLI not on PATH' };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const out = captureClaudeJson('plugin marketplace list --json', { timeout: 8000 });
|
|
164
|
+
const parsed = JSON.parse(out);
|
|
165
|
+
// Tolerate both shapes: object map keyed by name, or array of { name, … }.
|
|
166
|
+
const names = Array.isArray(parsed)
|
|
167
|
+
? parsed.map(m => m && m.name).filter(Boolean)
|
|
168
|
+
: Object.keys(parsed || {});
|
|
169
|
+
if (names.includes(OFFICIAL_MARKETPLACE_NAME)) {
|
|
170
|
+
return { ok: true, alreadyPresent: true };
|
|
171
|
+
}
|
|
172
|
+
} catch (_) {
|
|
173
|
+
// Fall through to the add — if the add itself fails, we report that.
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
execSync(`claude plugin marketplace add ${OFFICIAL_MARKETPLACE_SOURCE} --scope user`, {
|
|
177
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
178
|
+
timeout: 60000 // first add clones the marketplace repo; allow time
|
|
179
|
+
});
|
|
180
|
+
return { ok: true, alreadyPresent: false };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return { ok: false, error: (err.message || '').split('\n')[0] };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Return the set of plugin ids (e.g. `typescript-lsp@claude-plugins-official`)
|
|
188
|
+
* currently installed at ANY scope. Used to skip already-installed plugins.
|
|
189
|
+
* Returns `Set<string>`; empty set on any failure (degrade open — we'd rather
|
|
190
|
+
* issue a redundant install than block on a parser hiccup).
|
|
191
|
+
*/
|
|
192
|
+
listInstalledClaudePlugins() {
|
|
193
|
+
const ids = new Set();
|
|
194
|
+
try {
|
|
195
|
+
const out = captureClaudeJson('plugin list --json', { timeout: 8000 });
|
|
196
|
+
const parsed = JSON.parse(out);
|
|
197
|
+
const entries = Array.isArray(parsed) ? parsed : Object.values(parsed || {});
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
// Be tolerant of shape drift: pull the id from `name`, `id`, or
|
|
200
|
+
// the object's own key.
|
|
201
|
+
const id = (entry && (entry.id || entry.name)) || null;
|
|
202
|
+
if (id) ids.add(id);
|
|
203
|
+
}
|
|
204
|
+
// Also handle the `{ "<id>@<marketplace>": [...] }` map shape that the
|
|
205
|
+
// on-disk `installed_plugins.json` uses, in case `list --json` mirrors it.
|
|
206
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
207
|
+
for (const key of Object.keys(parsed)) {
|
|
208
|
+
if (key.includes('@')) ids.add(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (_) { /* swallow — empty set means "install, don't skip" */ }
|
|
212
|
+
return ids;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Install the native Claude Code companion plugins for the given language
|
|
217
|
+
* server names. Idempotent: skips plugins already installed, skips adapters
|
|
218
|
+
* whose `claudePluginId()` is null.
|
|
219
|
+
*
|
|
220
|
+
* Gating:
|
|
221
|
+
* - `opts.toolsEnabled` (array, default `['claude']`): when `claude` is
|
|
222
|
+
* not in the array, the whole method is a silent no-op (Codex-only users
|
|
223
|
+
* don't want Claude marketplace mutation).
|
|
224
|
+
* - When the `claude` CLI is missing, returns `{ skipped, reason }` so the
|
|
225
|
+
* caller can render a manual-install hint.
|
|
226
|
+
*
|
|
227
|
+
* Returns `{ installed: [...], skipped: [...], manual: [...], marketplace }`.
|
|
228
|
+
* Never throws.
|
|
229
|
+
*/
|
|
230
|
+
async installClaudePlugins(serverNames, opts = {}) {
|
|
231
|
+
const {
|
|
232
|
+
toolsEnabled = ['claude'],
|
|
233
|
+
scope = 'user', // LSP plugins are machine-wide useful — avoid N project copies
|
|
234
|
+
interactive = false
|
|
235
|
+
} = opts;
|
|
236
|
+
|
|
237
|
+
if (!Array.isArray(toolsEnabled) || !toolsEnabled.includes('claude')) {
|
|
238
|
+
return { skipped: [], installed: [], manual: [], reason: 'claude not in tools.enabled' };
|
|
239
|
+
}
|
|
240
|
+
if (!this.isClaudeCliAvailable()) {
|
|
241
|
+
return {
|
|
242
|
+
skipped: serverNames.map(n => ({ name: n, reason: 'claude CLI not on PATH' })),
|
|
243
|
+
installed: [], manual: [],
|
|
244
|
+
reason: 'claude CLI not on PATH'
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Resolve <serverName> → <claudePluginId>, dropping adapters without one.
|
|
249
|
+
const targets = [];
|
|
250
|
+
for (const name of serverNames) {
|
|
251
|
+
try {
|
|
252
|
+
const a = getAdapter(name, this.cwd);
|
|
253
|
+
const pid = a.claudePluginId();
|
|
254
|
+
if (pid) targets.push({ name, label: a.label, pluginId: pid });
|
|
255
|
+
} catch { /* unknown adapter — ignore */ }
|
|
256
|
+
}
|
|
257
|
+
if (!targets.length) {
|
|
258
|
+
return { installed: [], skipped: [], manual: [], reason: 'no adapters declare a Claude plugin id' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Idempotency 1: marketplace add (no-op if already registered).
|
|
262
|
+
const mkt = this.ensureClaudeMarketplace();
|
|
263
|
+
if (!mkt.ok) {
|
|
264
|
+
return {
|
|
265
|
+
installed: [],
|
|
266
|
+
skipped: targets.map(t => ({ name: t.name, reason: `marketplace not registered (${mkt.error || 'unknown'})` })),
|
|
267
|
+
manual: targets.map(t => ({
|
|
268
|
+
name: t.name,
|
|
269
|
+
label: t.label,
|
|
270
|
+
command: `claude plugin marketplace add ${OFFICIAL_MARKETPLACE_SOURCE} && claude plugin install ${t.pluginId} --scope ${scope}`
|
|
271
|
+
})),
|
|
272
|
+
marketplace: mkt
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Idempotency 2: skip plugins already installed at any scope.
|
|
277
|
+
const already = this.listInstalledClaudePlugins();
|
|
278
|
+
|
|
279
|
+
const installed = [];
|
|
280
|
+
const skipped = [];
|
|
281
|
+
const manual = [];
|
|
282
|
+
|
|
283
|
+
for (const t of targets) {
|
|
284
|
+
if (already.has(t.pluginId)) {
|
|
285
|
+
skipped.push({ name: t.name, reason: 'plugin already installed' });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (interactive) {
|
|
290
|
+
const ok = await UI.confirm(
|
|
291
|
+
`Install native Claude Code plugin \`${t.pluginId}\` (--scope ${scope})?`,
|
|
292
|
+
true
|
|
293
|
+
);
|
|
294
|
+
if (!ok) { skipped.push({ name: t.name, reason: 'user declined' }); continue; }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const spinner = UI.spinner(`Installing ${t.pluginId}…`).start();
|
|
298
|
+
try {
|
|
299
|
+
execSync(`claude plugin install ${t.pluginId} --scope ${scope}`, {
|
|
300
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
301
|
+
timeout: 60000
|
|
302
|
+
});
|
|
303
|
+
spinner.succeed(`Installed ${t.pluginId}`);
|
|
304
|
+
installed.push({ name: t.name, pluginId: t.pluginId });
|
|
305
|
+
} catch (err) {
|
|
306
|
+
spinner.fail(`Failed to install ${t.pluginId}`);
|
|
307
|
+
manual.push({
|
|
308
|
+
name: t.name,
|
|
309
|
+
label: t.label,
|
|
310
|
+
command: `claude plugin install ${t.pluginId} --scope ${scope}`
|
|
311
|
+
});
|
|
312
|
+
skipped.push({ name: t.name, reason: (err.message || '').split('\n')[0] });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { installed, skipped, manual, marketplace: mkt };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* For each declared server, report whether the matching Claude plugin is
|
|
321
|
+
* installed. Returns `[{ name, pluginId, installed }]`. Adapters without a
|
|
322
|
+
* `claudePluginId()` are reported as `{ pluginId: null, installed: null }`
|
|
323
|
+
* so callers can filter them out.
|
|
324
|
+
*/
|
|
325
|
+
verifyClaudePlugins(serverNames) {
|
|
326
|
+
const already = this.listInstalledClaudePlugins();
|
|
327
|
+
return serverNames.map(name => {
|
|
328
|
+
let a;
|
|
329
|
+
try { a = getAdapter(name, this.cwd); }
|
|
330
|
+
catch { return { name, pluginId: null, installed: null }; }
|
|
331
|
+
const pid = a.claudePluginId();
|
|
332
|
+
if (!pid) return { name, pluginId: null, installed: null };
|
|
333
|
+
return { name, pluginId: pid, installed: already.has(pid) };
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
94
337
|
/**
|
|
95
338
|
* Verify each named server's binary is reachable. Returns
|
|
96
339
|
* `[{ name, ok, output | error }]`. Never throws.
|