baldart 3.13.0 → 3.14.1

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 CHANGED
@@ -5,6 +5,58 @@ 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.1] - 2026-05-23
9
+
10
+ Hotfix for the `.framework/` gitignore self-heal shipped in v3.14.0. The detector misread `git check-ignore -v` output: when `check-ignore` matched a negation pattern (`!.framework`), it still reported the match, and `checkFrameworkIgnored` flagged the path as ignored. The result: every consumer who hit the self-heal on first run got `.framework/ is STILL ignored after auto-heal` and `process.exit(1)` — the heal *had* actually worked (the negation was in place), but the verifier didn't recognize its own fix. Reported during the v3.13.0 → v3.14.0 update in a downstream consumer.
11
+
12
+ ### Fixed
13
+
14
+ - **`GitUtils.checkFrameworkIgnored()` recognizes negation patterns** in `src/utils/git.js`. Per `gitignore(5)`, a leading `!` flips the match semantics: `check-ignore` still prints the pattern (matching ≠ ignored), but the path is RE-INCLUDED, not ignored. The detector now inspects the pattern, returns `{ ignored: false, negatedBy }` on a negation match, and `ensureFrameworkNotIgnored`'s post-heal recheck reports `stillIgnored: false` as expected. Smoke-tested end-to-end: `.gitignore` with `.framework` → heal → recheck reports not-ignored → idempotent second call is a no-op → `git add .framework/foo` succeeds.
15
+ - **CHANGELOG consolidation for v3.14.0.** The original 3.14.0 entry had a duplicate `### Added` / `### Notes` block (LSP-only Notes + gitignore-only Notes side by side). Merged into a single Notes section so the release reads as one feature shipped, not two.
16
+
17
+ ### Notes
18
+
19
+ - **v3.14.0 is functionally broken on first install of the heal.** Consumers who upgrade `npm i -g baldart@latest` get 3.14.1 directly and never observe the regression. Consumers who already updated to 3.14.0 (CLI binary or `npx baldart`) should reinstall: `npm i -g baldart@latest`, then re-run `baldart` — the heal converges on the second pass because the negation block already in `.gitignore` short-circuits the detector cleanly.
20
+ - **No framework payload changes.** Single-file fix in `src/utils/git.js` plus CHANGELOG.
21
+
22
+ ## [3.14.0] - 2026-05-23
23
+
24
+ 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.
25
+
26
+ ### LSP install completeness
27
+
28
+ 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`.
29
+
30
+ ### `.framework/` `.gitignore` self-heal
31
+
32
+ The other historical sharp edge in BALDART: consumers who had `.framework` (or a parent pattern like `framework/` or `*framework*`) in `.gitignore` saw every `baldart push` and most `baldart update` autofix flows blow up with `paths are ignored by one of your .gitignore files`, leaving half-finished `chore:` commits on HEAD and requiring a manual `git reset --hard` to recover. The path is a git subtree managed by BALDART — it MUST be tracked — and the failure mode was both confusing (the user never added `.framework` to ignore on purpose) and unfixable from inside the broken flow. v3.14.0 detects the collision and rewrites `.gitignore` idempotently at the top of every install / update / push, so a single `baldart` invocation always converges to a working state.
33
+
34
+ ### Added
35
+
36
+ - **`GitUtils.checkFrameworkIgnored()` + `GitUtils.ensureFrameworkNotIgnored()`** in `src/utils/git.js` — central, idempotent self-heal for the `.framework/` ↔ `.gitignore` collision. `checkFrameworkIgnored` runs `git check-ignore -v --no-index -- .framework` and returns `{ ignored, source, line, pattern, raw }` (degrade-open: simple-git throws on non-zero exit, treated as "not ignored"). `ensureFrameworkNotIgnored` strips standalone `.framework` / `.framework/` / `/.framework` rules from the project `.gitignore`, appends a single negation block (`!.framework` + `!.framework/**`) with a marker comment so re-runs are no-ops, then re-runs the check and surfaces `stillIgnored` if a rule still wins from outside the repo (parent-dir `.gitignore`, exotic `core.excludesFile`). Returns `{ wasFixed, stillIgnored, removedLines, appendedNegation, source, pattern, recheck }`. Never throws — callers can safely wrap it in best-effort try/catch.
37
+ - **`baldart add` / `baldart update` / `baldart push` auto-heal `.gitignore`** — each command calls `ensureFrameworkNotIgnored()` at the top of its flow, before `git subtree add` / `git subtree pull` / any `git add` runs. Healing prints a yellow warning so the user sees what changed, then continues without prompting (purely local file rewrite, fully idempotent). If a rule outside the project still wins, the flow exits with a clear, actionable message instead of failing deep in the autofix step with the cryptic `paths are ignored` git error.
38
+ - **`baldart doctor` `.gitignore` diagnostic + `unignore-framework` action** — `detectState` populates `state.frameworkIgnored` with the same payload; the status table renders a yellow `.gitignore IGNORES .framework/ (<source>:<line>) — will be auto-healed` row when triggered, and the planner inserts an `autoOk: true` action that calls the same helper. So `baldart` (no-arg doctor mode) detects, reports, and self-heals in a single invocation. Backfills the heal for consumers predating v3.14.0 that hit the broken-push state.
39
+ - **`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.
40
+ - **`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.
41
+ - **`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).
42
+ - **`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).
43
+ - **`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`.
44
+ - **`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.
45
+
46
+ ### Changed
47
+
48
+ - **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.
49
+
50
+ ### Notes
51
+
52
+ - **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).
53
+ - **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.
54
+ - **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.
55
+ - **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.
56
+ - **No framework payload changes.** All work is inside `src/` — adapters, installer, configure command, doctor command, and `GitUtils`. 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`).
57
+ - **`.gitignore` heal is 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 (the negation block is added once, future calls match the marker and skip).
58
+ - **`.gitignore` heal has no false positives.** The strip filter matches only the exact forms (`.framework`, `.framework/`, `/.framework`, `/.framework/`, `.framework/*`, `/.framework/*`) — `.frameworks/` or `framework/something.bak` patterns are preserved untouched.
59
+
8
60
  ## [3.13.0] - 2026-05-23
9
61
 
10
62
  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.13.0
1
+ 3.14.1
@@ -45,7 +45,7 @@ so existing behavior is preserved).
45
45
 
46
46
  ---
47
47
 
48
- ## 2. The four moving parts
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 ← high-level install+verify
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 action)
65
+ │ src/commands/doctor.js (verify + lsp-fix + plugin-fix)
65
66
  └──────────────┬──────────────────────────────────────────────────────┘
66
- │ writes installed_servers; surfaces broken 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 never speak LSP themselves they just describe *how to install*
89
- the language server. Actual symbol resolution at runtime happens through
90
- whatever Claude Code plugin / MCP server the consumer has loaded.
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 | Reserved: id of a Claude Code code-intelligence plugin |
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` so the user retains veto power.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.13.0",
3
+ "version": "3.14.1",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -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.
@@ -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 value = total === 0
537
- ? 'enabled but no servers installed'
538
- : broken === 0
539
- ? `${total} server(s) verified (${state.lspInstalled.join(', ')})`
540
- : `${broken}/${total} broken (${state.lspBroken.join(', ')})`;
541
- const severity = total === 0 || broken > 0 ? 'warn' : 'ok';
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'));
Binary file
@@ -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,104 @@ 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
+ const pattern = m ? m[3] : null;
202
+ // A leading `!` makes it a NEGATION pattern — the path is re-included,
203
+ // not ignored. `git check-ignore` still prints it (matching != ignored).
204
+ const isNegation = pattern ? pattern.startsWith('!') : false;
205
+ if (isNegation) return { ignored: false, negatedBy: pattern };
206
+ return {
207
+ ignored: true,
208
+ source: m ? m[1] : 'unknown',
209
+ line: m ? Number(m[2]) : null,
210
+ pattern,
211
+ raw: line
212
+ };
213
+ } catch (_) {
214
+ // `git check-ignore` exits 1 when the path is not ignored — simple-git
215
+ // throws on non-zero. Treat as "not ignored".
216
+ return { ignored: false };
217
+ }
218
+ }
219
+
220
+ async ensureFrameworkNotIgnored() {
221
+ const status = await this.checkFrameworkIgnored();
222
+ if (!status.ignored) return { wasFixed: false };
223
+
224
+ const gitignorePath = path.join(this.cwd, '.gitignore');
225
+ const marker = '# BALDART: .framework/ is a git subtree — never ignore (auto-managed)';
226
+ const negationBlock = `\n${marker}\n!.framework\n!.framework/**\n`;
227
+
228
+ let original = '';
229
+ if (fs.existsSync(gitignorePath)) {
230
+ original = fs.readFileSync(gitignorePath, 'utf8');
231
+ }
232
+
233
+ // 1. Strip standalone .framework rules from the project .gitignore so we
234
+ // don't leave stale "do ignore" lines that future maintainers might
235
+ // notice and try to "fix" by removing our negation.
236
+ const lines = original.split('\n');
237
+ const stripped = lines.filter((line) => {
238
+ const t = line.trim();
239
+ return !(
240
+ t === '.framework' ||
241
+ t === '.framework/' ||
242
+ t === '/.framework' ||
243
+ t === '/.framework/' ||
244
+ t === '.framework/*' ||
245
+ t === '/.framework/*'
246
+ );
247
+ });
248
+ const removedLines = lines.length - stripped.length;
249
+
250
+ // 2. Append the negation block (only once — idempotent across re-runs).
251
+ let next = stripped.join('\n');
252
+ if (!next.includes(marker)) {
253
+ if (next.length && !next.endsWith('\n')) next += '\n';
254
+ next += negationBlock;
255
+ }
256
+
257
+ if (next !== original) {
258
+ fs.writeFileSync(gitignorePath, next, 'utf8');
259
+ }
260
+
261
+ // 3. Verify the heal actually took effect. If the rule lives somewhere we
262
+ // cannot rewrite from here (a parent-dir .gitignore outside the repo,
263
+ // a non-standard core.excludesFile that overrides the project file),
264
+ // surface the leftover state to the caller instead of pretending.
265
+ const recheck = await this.checkFrameworkIgnored();
266
+
267
+ return {
268
+ wasFixed: true,
269
+ stillIgnored: recheck.ignored,
270
+ removedLines,
271
+ appendedNegation: !original.includes(marker),
272
+ source: status.source,
273
+ pattern: status.pattern,
274
+ recheck
275
+ };
276
+ }
179
277
  }
180
278
 
181
279
  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 null; }
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 null; }
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 null; }
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 null; }
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
- /** Claude Code code-intelligence plugin id, if any. Null = use generic LSP. */
36
- claudePluginId() { return null; }
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.