baldart 3.33.0 → 3.34.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 +36 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/framework/.claude/skills/lsp-bootstrap/SKILL.md +30 -14
- package/framework/.claude/skills/worktree-manager/SKILL.md +26 -2
- package/framework/agents/code-search-protocol.md +8 -0
- package/framework/docs/LSP-LAYER.md +52 -24
- package/framework/docs/PROJECT-CONFIGURATION.md +2 -2
- package/package.json +1 -1
- package/src/commands/configure.js +20 -1
- package/src/commands/doctor.js +69 -4
- package/src/utils/__tests__/githooks-active.test.js +63 -0
- package/src/utils/githooks.js +233 -0
- package/src/utils/lsp-adapters/python.js +11 -7
- package/src/utils/lsp-adapters/typescript.js +23 -8
- package/src/utils/lsp-installer.js +86 -14
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ 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.34.0] - 2026-05-30
|
|
9
|
+
|
|
10
|
+
Adds an **assert-active git-hooks health check** to `baldart doctor` and the `worktree-manager` verify step. A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the repo's versioned hooks lived in `scripts/git-hooks/` — the pre-commit schema guard and pre-push were **silently inactive for days**. The existing worktree-manager verify (`git config core.hooksPath || true`) only *read* the value without asserting anything, so it never caught the drift. Same class as the LSP bug: the framework "verifies" by reading a proxy instead of asserting the real state. This moves it from read-only to assert-active. **WARN-only** — the check never mutates `core.hooksPath` (a wrong heuristic + auto-fix would be worse than the original bug); it surfaces the exact remediation command and lets the user apply it. **No new `baldart.config.yml` keys** — it's a universal, always-on, filesystem-autodetected check, so the schema-propagation rule does not apply.
|
|
11
|
+
|
|
12
|
+
### Added — git-hooks health check (assert-active)
|
|
13
|
+
|
|
14
|
+
- **[src/utils/githooks.js](src/utils/githooks.js)**: new, self-contained, fail-safe utility (distinct from `src/utils/hooks.js`, which manages `.claude/settings.json` hooks). `getStatus(cwd)` detects the repo's versioned-hooks directory (`.husky`, `.githooks`, `scripts/git-hooks`, `githooks` — first that exists and contains a standard-named hook), resolves the **active** hooks dir explicitly (`core.hooksPath` → relative resolved against `git rev-parse --show-toplevel`, absolute verbatim; unset → `<git-dir>/hooks`, handling the worktree `.git`-is-a-file case), and asserts the active dir serves the versioned hooks. **`git rev-parse --git-path hooks` is deliberately NOT used** — empirically it returns the configured value relative/unresolved and historically some git versions ignored `core.hooksPath` for it. Returns `checked:false` (silent, `ok:true`) when no managed hooks exist or on any error, so the check never produces a false WARN.
|
|
15
|
+
- **[src/utils/githooks.js](src/utils/githooks.js)**: pure exported `isActiveServing(activeAbs, expectedAbs)` — identical-or-nested decision (covers husky v9, which sets `core.hooksPath=.husky/_` with user hooks in `.husky/`). Paths are canonicalized (symlink-resolved, handling non-existent leaves) before comparison so a symlinked ancestor (macOS `/var`→`/private/var`, symlinked repo/home) never yields a false INACTIVE. Executability (`-x`) is asserted only for raw dirs git execs directly; husky user hooks (not required `+x` in v9) skip the assert.
|
|
16
|
+
- **[src/commands/doctor.js](src/commands/doctor.js)**: probe wired into `detectState` (`state.gitHooks`), a `Git hooks` line in `renderDiagnostic` (silent when `checked:false`), and an **advisory print-only** action in `planActions` (`githooks-health`) whose `run()` prints the WARN + fix command and **never mutates** `core.hooksPath` (safe under `--auto`).
|
|
17
|
+
- **[framework/.claude/skills/worktree-manager/SKILL.md](framework/.claude/skills/worktree-manager/SKILL.md)**: step 4 (isolation setup) replaces the silent `git config core.hooksPath || true` read with a lean **assert** — detects the versioned-hooks dir, resolves the active dir, and echoes a loud `⚠️ WARNING` + the `git config core.hooksPath <dir>` fix on mismatch (matches husky v9 `.husky/_` and `<dir>/*` as active). The worktree shares `core.hooksPath` via `.git/commondir`, so the same resolution applies.
|
|
18
|
+
|
|
19
|
+
### Tests
|
|
20
|
+
|
|
21
|
+
- **[src/utils/__tests__/githooks-active.test.js](src/utils/__tests__/githooks-active.test.js)**: new — 8 pure fixtures for `isActiveServing` (the mayo bug, husky v9 nesting, identical dirs, prefix-not-a-boundary, trailing-sep normalization).
|
|
22
|
+
- **[scripts/test-githooks.sh](scripts/test-githooks.sh)**: new — drives **real** git repos: misconfig (`hooksPath=.git/hooks`, hooks in `scripts/git-hooks`) → inactive + fix; correctly wired → ok; non-executable raw hook → ok=false + chmod fix; husky v9 → active, `-x` assert skipped; no managed hooks → `checked:false` (silent).
|
|
23
|
+
|
|
24
|
+
## [3.33.1] - 2026-05-30
|
|
25
|
+
|
|
26
|
+
Fixes a **false-positive in the LSP layer's verify step** that let `/lsp-bootstrap` (and `baldart configure` / `doctor`) report a language server as `verified` while every real LSP call failed with `Executable not found in $PATH` — a dead layer masked by `codebase-architect`'s silent Grep fallback. Root cause was a **resolution mismatch**: verify used `npx --no-install typescript-language-server --version` (resolves from `node_modules/.bin`), but the consumer — Claude Code's LSP tool — spawns the server **by name from `$PATH`**. The npm-dev install (`npm install --save-dev`) put the binary in `node_modules/.bin`, which is *not* on `$PATH`, so verify passed and spawn failed. The npm-managed adapters now install **globally** so the binary lands on `$PATH`, and verify checks `$PATH` reachability the *same way the consumer spawns it*. **No new `baldart.config.yml` keys** — reachability is computed, not configured, so the schema-propagation rule does not apply.
|
|
27
|
+
|
|
28
|
+
### Fixed — LSP verify no longer reports unreachable servers as `verified`
|
|
29
|
+
|
|
30
|
+
- **[src/utils/lsp-installer.js](src/utils/lsp-installer.js)**: `verifyServers()` rewritten around a 3-state taxonomy. It first probes `$PATH` reachability via `command -v <binary>` (new `_isOnPath` helper — mirrors the consumer's by-name spawn exactly), then runs the functional `verifyCommand()`. Results carry `status` ∈ `verified` (on `$PATH`, probe passed) / `installed-but-unreachable` (present in `node_modules` but not on `$PATH` — new `_isInstalledLocally` helper fingerprints the legacy devDep install; `fix` = global-install command) / `unreachable` / `not-installed` / `unknown-adapter`. Never throws.
|
|
31
|
+
- **[src/utils/lsp-adapters/typescript.js](src/utils/lsp-adapters/typescript.js)** + **[src/utils/lsp-adapters/python.js](src/utils/lsp-adapters/python.js)**: `installMode` `npm-dev` → `npm-global`; `installCommand()` → `npm install -g …`; `verifyCommand()` → `<binary> --version` (resolved from `$PATH`, mirroring the consumer) instead of `npx --no-install …`. System adapters (go/rust/ruby) were already PATH-correct and are unchanged.
|
|
32
|
+
|
|
33
|
+
### Changed — `configure` records unreachable servers; `doctor` fix is now real
|
|
34
|
+
|
|
35
|
+
- **[src/commands/configure.js](src/commands/configure.js)**: **every** known-adapter result that isn't cleanly `verified` is now recorded in `lsp.installed_servers` (like system-mode pending installs) instead of being dropped by the old `filter(v => v.ok)` — otherwise re-runs reinstall blindly and `doctor` never sees the mismatch. This covers both `installed-but-unreachable` and the npm-global happy-path trap where `npm install -g` exits 0 but npm's global bin dir isn't on the current `$PATH` (→ status `not-installed`, since `npx` can't see the global prefix either) — without this, the primary configure path would have silently reintroduced the dead layer. The fix command (+ `npm prefix -g` PATH hint) is surfaced inline for each.
|
|
36
|
+
- **[src/commands/doctor.js](src/commands/doctor.js)**: the `lsp-fix` remediation now reinstalls globally (a real fix that puts the binary on `$PATH`), where before it re-ran `npm install --save-dev` — a no-op for the reachability problem. The `why` text explains the `$PATH` contract, and a post-reinstall re-verify surfaces the `npm prefix -g` PATH escape when a server stays unreachable (so a misconfigured global bin dir doesn't trap the user in a reinstall loop).
|
|
37
|
+
|
|
38
|
+
### Docs
|
|
39
|
+
|
|
40
|
+
- **[framework/.claude/skills/lsp-bootstrap/SKILL.md](framework/.claude/skills/lsp-bootstrap/SKILL.md)**: Output Contract redefines `verified` as `$PATH`-reachable, adds the `installed-but-unreachable` status, and documents the **cold-start caveat** (first query may under-report references until indexing completes).
|
|
41
|
+
- **[framework/agents/code-search-protocol.md](framework/agents/code-search-protocol.md)**: new fallback rule for cold-start under-reporting — re-issue the first query before trusting a suspiciously-low reference count; never declare a helper unused off one cold find-references.
|
|
42
|
+
- **[framework/docs/LSP-LAYER.md](framework/docs/LSP-LAYER.md)** + **[framework/docs/PROJECT-CONFIGURATION.md](framework/docs/PROJECT-CONFIGURATION.md)**: adapter-contract table, install-mode descriptions, `verifyServers` contract, and troubleshooting §6.3/§6.4 updated for global install + `$PATH` reachability + the PATH-mismatch cause.
|
|
43
|
+
|
|
8
44
|
## [3.33.0] - 2026-05-30
|
|
9
45
|
|
|
10
46
|
Closes a **structural silent-partial-install** failure mode (observed on the `mayo` consumer, 3.28→3.31, still live in 3.32.0; same class as the historic v3.27.1 bug): `add` was **non-atomic** — the hook registration ran *after* optional blocking prompts (git aliases, configure). In a non-TTY context (Claude's Bash tool never has a TTY on stdin) or on any interruption, those `inquirer` prompts throw/hang, so `.framework/` + `state.json` landed on the new version while `Hooks.registerAll` never ran — a half-install that `baldart status` still reported as "Valid". The fix makes install atomic (correctness-critical mutations run **before** every optional prompt), adds a **post-flight hook assert** that refuses to declare success with any active hook missing, and finally exposes `add --yes` so agents/CI can install unattended. Two additive, non-breaking follow-ups ship alongside. **No new `baldart.config.yml` keys** — `--yes` is a CLI flag, so the schema-propagation rule does not apply.
|
package/README.md
CHANGED
|
@@ -239,7 +239,7 @@ The `ui-expert` agent is upgraded from a generic baseline to a world-class UI/UX
|
|
|
239
239
|
|
|
240
240
|
### LSP Symbol Search Layer (new in v3.10.0)
|
|
241
241
|
|
|
242
|
-
When `features.has_lsp_layer: true`, `codebase-architect` and the code-exploration skills (`context-primer`, `bug`, `prd`, `new`, `simplify`) prefer LSP `find-references` / `go-to-definition` over Grep for identifier-shaped queries — the filtering happens **before** Claude reads files, so a common function name no longer dumps thousands of textual matches into context. Opt-in at `baldart configure`; BALDART installs the matching language servers (npm
|
|
242
|
+
When `features.has_lsp_layer: true`, `codebase-architect` and the code-exploration skills (`context-primer`, `bug`, `prd`, `new`, `simplify`) prefer LSP `find-references` / `go-to-definition` over Grep for identifier-shaped queries — the filtering happens **before** Claude reads files, so a common function name no longer dumps thousands of textual matches into context. Opt-in at `baldart configure`; BALDART installs the matching language servers (global npm install for TypeScript/Python — the binary must be on `$PATH` because Claude Code's LSP tool spawns it by name; system commands printed for Go/Rust/Ruby). Grep remains the fallback for free-text queries and degraded states. See [`framework/agents/code-search-protocol.md`](framework/agents/code-search-protocol.md).
|
|
243
243
|
|
|
244
244
|
### Commands
|
|
245
245
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.34.0
|
|
@@ -3,8 +3,8 @@ name: lsp-bootstrap
|
|
|
3
3
|
description: >
|
|
4
4
|
Install, verify, and document the LSP symbol-search layer for this project.
|
|
5
5
|
Detects languages (TypeScript, Python, Go, Rust, Ruby), installs the matching
|
|
6
|
-
language servers (npm
|
|
7
|
-
verifies binaries are reachable, and writes lsp.installed_servers to
|
|
6
|
+
language servers (global npm install where possible, system command otherwise),
|
|
7
|
+
verifies binaries are reachable on $PATH, and writes lsp.installed_servers to
|
|
8
8
|
baldart.config.yml. Use when the user says /lsp-bootstrap, "install LSP",
|
|
9
9
|
"set up symbol search", or after enabling features.has_lsp_layer for the
|
|
10
10
|
first time. Idempotent — re-running on an already-configured project
|
|
@@ -32,15 +32,22 @@ context on textual collisions; with it, references resolve to the same symbol
|
|
|
32
32
|
present (markers: `tsconfig.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`,
|
|
33
33
|
`Gemfile`, plus heuristic fallbacks).
|
|
34
34
|
3. For each detected language:
|
|
35
|
-
- **npm-
|
|
36
|
-
|
|
35
|
+
- **npm-global** adapters (TypeScript, Python via pyright): runs `npm install
|
|
36
|
+
-g <pkg>` with user confirmation. The install is **global** on purpose —
|
|
37
|
+
the consumer (Claude Code's LSP tool) spawns the server by name from
|
|
38
|
+
`$PATH`, and a project-local devDep under `node_modules/.bin/` is not on
|
|
39
|
+
`$PATH`, so it would never be reachable.
|
|
37
40
|
- **system** adapters (Go gopls, Rust rust-analyzer, Ruby ruby-lsp): prints
|
|
38
41
|
the install command for the user to run in a separate terminal. Does NOT
|
|
39
42
|
execute system-level installs automatically.
|
|
40
|
-
4. Verifies each binary is reachable
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
4. Verifies each binary is **reachable on `$PATH` the same way the consumer
|
|
44
|
+
spawns it** (`command -v <binary>`), then runs `<binary> --version` for the
|
|
45
|
+
functional check. A binary present only in `node_modules/.bin` (legacy
|
|
46
|
+
devDep) is reported as `installed-but-unreachable`, NOT `verified`.
|
|
47
|
+
5. Updates `lsp.installed_servers` in `baldart.config.yml`. Both `verified` and
|
|
48
|
+
`installed-but-unreachable` servers are recorded (the latter so re-runs don't
|
|
49
|
+
reinstall blindly and `baldart doctor` keeps flagging the mismatch);
|
|
50
|
+
system-mode pending installs are recorded too.
|
|
44
51
|
6. Prints a summary table and points the user to
|
|
45
52
|
`framework/agents/code-search-protocol.md` for runtime behavior.
|
|
46
53
|
|
|
@@ -66,8 +73,11 @@ context on textual collisions; with it, references resolve to the same symbol
|
|
|
66
73
|
4. **Install.** Run the appropriate `installCommand()`. Capture failures into a
|
|
67
74
|
"Skipped" bucket — never abort the whole skill on one failure.
|
|
68
75
|
|
|
69
|
-
5. **Verify.** For each installed server,
|
|
70
|
-
|
|
76
|
+
5. **Verify.** For each installed server, the installer first checks `$PATH`
|
|
77
|
+
reachability (`command -v <binary>` — exactly how the consumer spawns it),
|
|
78
|
+
then runs `verifyCommand()`. Mark each result `verified`,
|
|
79
|
+
`installed-but-unreachable` (present in node_modules but not on `$PATH` —
|
|
80
|
+
print the global-install fix), or `not-installed`.
|
|
71
81
|
|
|
72
82
|
6. **Persist.** Update `baldart.config.yml`:
|
|
73
83
|
```yaml
|
|
@@ -86,19 +96,25 @@ A single short status block — never a multi-page report. Format:
|
|
|
86
96
|
|
|
87
97
|
```
|
|
88
98
|
LSP bootstrap:
|
|
89
|
-
✓ typescript-language-server (npm
|
|
90
|
-
|
|
99
|
+
✓ typescript-language-server (npm -g, on $PATH, verified)
|
|
100
|
+
⚠ pyright — installed but not on $PATH; run `npm install -g pyright`, then `baldart doctor`
|
|
91
101
|
⚠ gopls — run `go install golang.org/x/tools/gopls@latest`, then `baldart doctor`
|
|
92
102
|
· ruby — not detected (no Gemfile)
|
|
93
103
|
Config updated: baldart.config.yml lsp.installed_servers = [typescript, python, go]
|
|
94
104
|
Next: codebase-architect will now prefer LSP find-references over Grep for symbols.
|
|
105
|
+
Note: the first LSP query after a cold start may under-report references until the
|
|
106
|
+
workspace finishes indexing — re-run the query once warm if a count looks low.
|
|
95
107
|
```
|
|
96
108
|
|
|
109
|
+
`verified` is reserved for binaries that resolve on `$PATH` (the consumer's
|
|
110
|
+
spawn path). Never report `verified` for a binary that only `npx --no-install`
|
|
111
|
+
can find — that is precisely the false positive this skill must not emit.
|
|
112
|
+
|
|
97
113
|
## Notes
|
|
98
114
|
|
|
99
115
|
- This skill never edits source code or installs binaries that aren't language
|
|
100
|
-
servers — its only side effect is `npm install
|
|
101
|
-
YAML write to `baldart.config.yml`.
|
|
116
|
+
servers — its only side effect is `npm install -g <lsp-package>` (global, so
|
|
117
|
+
the binary is reachable on `$PATH`) and a YAML write to `baldart.config.yml`.
|
|
102
118
|
- For Claude Code users: in addition to the binaries, you may need to load a
|
|
103
119
|
code-intelligence plugin (`/plugin` in Claude Code). That step is documented
|
|
104
120
|
in the printed output when applicable, but the skill does NOT manage Claude
|
|
@@ -411,8 +411,32 @@ else
|
|
|
411
411
|
echo "PORT=$PORT" >> .env.local
|
|
412
412
|
fi
|
|
413
413
|
|
|
414
|
-
# 5.
|
|
415
|
-
|
|
414
|
+
# 5. ASSERT git hooks are ACTIVE — not just readable.
|
|
415
|
+
# The worktree shares core.hooksPath via .git/commondir, so the same
|
|
416
|
+
# resolution applies here as in the main checkout. Reading the value
|
|
417
|
+
# (the old `git config core.hooksPath || true`) is NOT enough: it never
|
|
418
|
+
# catches the failure mode where core.hooksPath points at an empty dir
|
|
419
|
+
# while the repo's versioned hooks live elsewhere (pre-commit / pre-push
|
|
420
|
+
# silently inactive). Detect the versioned-hooks dir and assert it is served.
|
|
421
|
+
HOOK_SRC=""
|
|
422
|
+
for d in .husky .githooks scripts/git-hooks githooks; do
|
|
423
|
+
if [ -d "$d" ] && ls "$d" 2>/dev/null | grep -qE '^(pre-commit|pre-push|commit-msg)$'; then
|
|
424
|
+
HOOK_SRC="$d"; break
|
|
425
|
+
fi
|
|
426
|
+
done
|
|
427
|
+
if [ -n "$HOOK_SRC" ]; then
|
|
428
|
+
ACTIVE="$(git config --get core.hooksPath || echo .git/hooks)"
|
|
429
|
+
# Lean literal-string match (HOOK_SRC is relative). Full path resolution +
|
|
430
|
+
# canonicalization lives in `baldart doctor` (src/utils/githooks.js); an
|
|
431
|
+
# absolute core.hooksPath here would warn spuriously — rare, run doctor to confirm.
|
|
432
|
+
case "$ACTIVE" in
|
|
433
|
+
"$HOOK_SRC"|"$HOOK_SRC"/*|.husky/_) : ;; # active dir serves the versioned hooks (incl. husky v9 .husky/_)
|
|
434
|
+
*)
|
|
435
|
+
echo "⚠️ WARNING: git hooks INATTIVI — gli hook versionati sono in '$HOOK_SRC' ma core.hooksPath = '$ACTIVE'." >&2
|
|
436
|
+
echo " Esegui per riattivarli: git config core.hooksPath $HOOK_SRC" >&2
|
|
437
|
+
;;
|
|
438
|
+
esac
|
|
439
|
+
fi
|
|
416
440
|
```
|
|
417
441
|
|
|
418
442
|
### 5. Verify baseline
|
|
@@ -75,6 +75,14 @@ Before picking a tier, classify the query:
|
|
|
75
75
|
a path excluded by `tsconfig.json`).
|
|
76
76
|
- LSP times out (>8s) → fall back to Grep for this query, log the symptom in
|
|
77
77
|
the agent's reasoning so the user can re-run doctor.
|
|
78
|
+
- **Cold start under-reporting**: the first LSP query in a session can return
|
|
79
|
+
an incomplete set (e.g. find-references returning 1 hit where the symbol has
|
|
80
|
+
6) until the language server finishes indexing the workspace. If a reference
|
|
81
|
+
count looks suspiciously low for a symbol you expect to be widely used, treat
|
|
82
|
+
the first result as a warm-up: re-issue the same query once before trusting
|
|
83
|
+
it, or cross-check with a single Grep. Do not act on a cold-start count as if
|
|
84
|
+
it were exhaustive (e.g. don't declare a helper "unused / safe to delete"
|
|
85
|
+
off one cold find-references).
|
|
78
86
|
|
|
79
87
|
## Examples
|
|
80
88
|
|
|
@@ -38,8 +38,9 @@ function named `handleSubmit` declared in `src/forms/Login.tsx` returns
|
|
|
38
38
|
exactly the callsites that resolve to *that* declaration; the CLI utility's
|
|
39
39
|
unrelated `handleSubmit` is filtered out before any file is read.
|
|
40
40
|
|
|
41
|
-
The layer is **opt-in** (some consumers don't want
|
|
42
|
-
languages we don't have an adapter for yet) and
|
|
41
|
+
The layer is **opt-in** (some consumers don't want a global npm install for a
|
|
42
|
+
language server, some target languages we don't have an adapter for yet) and
|
|
43
|
+
**degrades gracefully** (when
|
|
43
44
|
LSP is unavailable, agents silently fall back to the legacy RAG → Grep flow,
|
|
44
45
|
so existing behavior is preserved).
|
|
45
46
|
|
|
@@ -133,8 +134,13 @@ configure prompt (the user can still say no). The prompt copy is
|
|
|
133
134
|
|
|
134
135
|
When the user confirms, for each detected language:
|
|
135
136
|
|
|
136
|
-
- **npm-
|
|
137
|
-
|
|
137
|
+
- **npm-global mode** (TS, Python via pyright): `npm install -g <pkg>` runs
|
|
138
|
+
in-process. The install is **global** — the consumer (Claude Code's LSP
|
|
139
|
+
tool) spawns the server by name from `$PATH`, so a project-local devDep in
|
|
140
|
+
`node_modules/.bin/` would never be reachable (this was the pre-v3.33.1
|
|
141
|
+
dead-layer bug). Reachable on `$PATH` → recorded as `verified`; present but
|
|
142
|
+
not on `$PATH` → recorded as `installed-but-unreachable` (with the
|
|
143
|
+
global-install fix printed).
|
|
138
144
|
- **system mode** (Go, Rust, Ruby): the install command is *printed*, never
|
|
139
145
|
executed. The server name is recorded as pending in `installed_servers`;
|
|
140
146
|
`baldart doctor` re-verifies on the next run and flags the mismatch if the
|
|
@@ -180,8 +186,10 @@ npx baldart # smart doctor (no-arg, default since v3.2.0)
|
|
|
180
186
|
```
|
|
181
187
|
|
|
182
188
|
`doctor.js` reads `config.lsp.installed_servers`. When
|
|
183
|
-
`lsp.auto_verify !== false`,
|
|
184
|
-
(
|
|
189
|
+
`lsp.auto_verify !== false`, the installer first checks `$PATH` reachability
|
|
190
|
+
(`command -v <binary>` — exactly how the consumer spawns the server), then runs
|
|
191
|
+
each adapter's `verifyCommand()` (e.g. `typescript-language-server --version`)
|
|
192
|
+
for the functional check. Output:
|
|
185
193
|
|
|
186
194
|
```
|
|
187
195
|
· LSP layer 2 server(s) verified (typescript, python)
|
|
@@ -268,10 +276,10 @@ Every adapter is a class exporting:
|
|
|
268
276
|
| `name` | string getter | Stable id used in `installed_servers` (e.g. `"typescript"`)|
|
|
269
277
|
| `label` | string getter | Human-readable for prompts/diagnostics |
|
|
270
278
|
| `binary` | string getter | Executable name expected on PATH or in node_modules |
|
|
271
|
-
| `installMode` | `"npm-
|
|
272
|
-
| `npmPackage` | string (npm-
|
|
279
|
+
| `installMode` | `"npm-global"` / `"system"` | How the installer treats this language |
|
|
280
|
+
| `npmPackage` | string (npm-global only) | What goes after `npm install -g` |
|
|
273
281
|
| `installCommand()` | string | The exact shell command (printed or executed) |
|
|
274
|
-
| `verifyCommand()` | string |
|
|
282
|
+
| `verifyCommand()` | string | Functional probe run by name from `$PATH` (e.g. `<binary> --version`); `$PATH` reachability is checked separately by the installer via `command -v <binary>` |
|
|
275
283
|
| `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 |
|
|
276
284
|
| `static detect(cwd)` | boolean | Pure: does the language live in this repo? |
|
|
277
285
|
|
|
@@ -289,7 +297,13 @@ to the REGISTRY in `lsp-adapters/index.js`.
|
|
|
289
297
|
- `recommend(langs?)` → entries with install metadata for UI consumption
|
|
290
298
|
- `installServers(names, { interactive=true, dryRun=false })` →
|
|
291
299
|
`{ installed, skipped, manual }` — returns rather than throws
|
|
292
|
-
- `verifyServers(names)` → `[{ name, ok, output
|
|
300
|
+
- `verifyServers(names)` → `[{ name, ok, status, output?, error?, fix? }]` —
|
|
301
|
+
never throws. `status` ∈ `verified` (on `$PATH`, probe passed) /
|
|
302
|
+
`installed-but-unreachable` (in node_modules but not on `$PATH`; `fix` =
|
|
303
|
+
global-install command) / `unreachable` (on `$PATH` but probe errored) /
|
|
304
|
+
`not-installed` / `unknown-adapter`. Reachability is `command -v <binary>`,
|
|
305
|
+
mirroring how the consumer spawns the server — never `npx --no-install`,
|
|
306
|
+
which resolves from node_modules and produces a false positive.
|
|
293
307
|
|
|
294
308
|
**Native Claude Code plugin layer (since v3.14.0):**
|
|
295
309
|
|
|
@@ -396,20 +410,34 @@ The autodetector found no supported language markers. Either:
|
|
|
396
410
|
|
|
397
411
|
### 6.3 "doctor says my server is broken but it works in my editor"
|
|
398
412
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
+
Two common causes:
|
|
414
|
+
|
|
415
|
+
1. **PATH-resolution mismatch (the usual culprit).** Your editor resolves the
|
|
416
|
+
language server through `node_modules/.bin`, `npx`, or its own bundled copy;
|
|
417
|
+
the Claude Code LSP tool spawns it **by name from `$PATH`**. A binary
|
|
418
|
+
installed as a project-local devDep is visible to the editor but reports
|
|
419
|
+
`installed-but-unreachable` here, because it isn't on `$PATH`. Fix: install
|
|
420
|
+
it globally (`npm install -g <pkg>`) — or, if you installed globally and it's
|
|
421
|
+
still unreachable, confirm npm's global bin dir (`npm prefix -g`/`npm bin -g`)
|
|
422
|
+
is on your `$PATH`. This was the pre-v3.33.1 false-positive bug: verify used
|
|
423
|
+
`npx --no-install` (node_modules resolution) and passed while the consumer's
|
|
424
|
+
`$PATH` spawn failed.
|
|
425
|
+
2. **Cold-start timeout.** `verifyCommand()` executes from the consumer cwd with
|
|
426
|
+
a hard 8s timeout. If the server takes longer than that to print `--version`
|
|
427
|
+
(rare, seen on first-run cold caches), verify fails. Re-run `baldart doctor`
|
|
428
|
+
once warm. If still failing, run the exact `command -v <binary>` and
|
|
429
|
+
`verifyCommand()` strings in your shell — the actual error will surface.
|
|
430
|
+
|
|
431
|
+
### 6.4 "I don't want a global npm install for the language server"
|
|
432
|
+
|
|
433
|
+
The npm-global default exists because the consumer spawns the server from
|
|
434
|
+
`$PATH`. If you'd rather manage the binary yourself (system pyright via uv, a
|
|
435
|
+
Volta/asdf shim, a Nix profile, whatever), do it your way — just make sure the
|
|
436
|
+
binary ends up on `$PATH`. Then write an overlay at
|
|
437
|
+
`.baldart/overlays/agents/codebase-architect.md` documenting your install path
|
|
438
|
+
and manually set `lsp.installed_servers: [python]` in the config. The runtime
|
|
439
|
+
layer doesn't care *how* the binary got onto `$PATH`, only that it's reachable
|
|
440
|
+
there. `baldart doctor` will continue to verify it via `command -v`.
|
|
413
441
|
|
|
414
442
|
---
|
|
415
443
|
|
|
@@ -216,11 +216,11 @@ Populated by `baldart configure` and the `/lsp-bootstrap` skill when `features.h
|
|
|
216
216
|
| Key | Meaning |
|
|
217
217
|
|---|---|
|
|
218
218
|
| `lsp.installed_servers` | List of language IDs (`typescript`, `python`, `go`, `rust`, `ruby`, …) whose server BALDART has recorded for this project. Identifiers map 1:1 to adapter files under `src/utils/lsp-adapters/`. |
|
|
219
|
-
| `lsp.auto_verify` | When `true` (default), `baldart doctor` re-verifies the binaries are reachable on every run. Disable on CI if the verify step is too noisy. |
|
|
219
|
+
| `lsp.auto_verify` | When `true` (default), `baldart doctor` re-verifies the binaries are reachable **on `$PATH`** (via `command -v`, mirroring how the Claude Code LSP tool spawns them) on every run. Disable on CI if the verify step is too noisy. |
|
|
220
220
|
|
|
221
221
|
**Install modes per adapter:**
|
|
222
222
|
|
|
223
|
-
- `typescript`, `python` — installed
|
|
223
|
+
- `typescript`, `python` — installed globally via npm (`npm install -g typescript-language-server typescript` / `pyright`). Global on purpose: the consumer spawns the server by name from `$PATH`, so a project-local devDep under `node_modules/.bin/` would never be reachable. A binary present only in `node_modules` is reported `installed-but-unreachable`, not `verified`.
|
|
224
224
|
- `go`, `rust`, `ruby` — installed via system tools (`go install …`, `rustup component add …`, `gem install …`). BALDART prints the command; the user runs it.
|
|
225
225
|
|
|
226
226
|
**Fallback contract.** When the LSP layer is enabled but a server is missing or broken, agents silently fall back to Grep — code search never fails because of LSP issues. `baldart doctor` surfaces the mismatch and offers reinstall.
|
package/package.json
CHANGED
|
@@ -706,7 +706,26 @@ async function interactivePrompts(merged, detected) {
|
|
|
706
706
|
}
|
|
707
707
|
const verify = installer.verifyServers(installed.map(i => i.name));
|
|
708
708
|
const verified = verify.filter(v => v.ok).map(v => v.name);
|
|
709
|
-
|
|
709
|
+
// Every known-adapter result that isn't cleanly `verified` is STILL
|
|
710
|
+
// recorded (like system-mode pending installs): dropping it would make
|
|
711
|
+
// every re-run reinstall and `baldart doctor` would never see the
|
|
712
|
+
// mismatch. This covers `installed-but-unreachable` AND the npm-global
|
|
713
|
+
// happy-path trap where `npm install -g` exited 0 but npm's global bin
|
|
714
|
+
// dir isn't on the current `$PATH` (→ status `not-installed`, since npx
|
|
715
|
+
// can't see the global prefix either). Surface the fix in both cases.
|
|
716
|
+
const needsAttention = verify.filter(v => !v.ok && v.status !== 'unknown-adapter');
|
|
717
|
+
for (const u of needsAttention) {
|
|
718
|
+
UI.warning(`${u.name}: not reachable on $PATH (status: ${u.status}) — Claude Code's LSP tool can't spawn it.`);
|
|
719
|
+
if (u.fix) {
|
|
720
|
+
UI.code(u.fix, 'Run this, then ensure npm\'s global bin dir (`npm prefix -g` → /bin) is on $PATH; re-run `baldart doctor` to verify.');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const newSet = new Set([
|
|
724
|
+
...(merged.lsp.installed_servers || []),
|
|
725
|
+
...verified,
|
|
726
|
+
...needsAttention.map(u => u.name),
|
|
727
|
+
...manual.map(m => m.name)
|
|
728
|
+
]);
|
|
710
729
|
merged.lsp.installed_servers = Array.from(newSet);
|
|
711
730
|
if (skipped.length) {
|
|
712
731
|
UI.warning(`Skipped: ${skipped.map(s => `${s.name} (${s.reason})`).join(', ')}`);
|
package/src/commands/doctor.js
CHANGED
|
@@ -30,6 +30,7 @@ const GitUtils = require('../utils/git');
|
|
|
30
30
|
const UI = require('../utils/ui');
|
|
31
31
|
const State = require('../utils/state');
|
|
32
32
|
const Hooks = require('../utils/hooks');
|
|
33
|
+
const GitHooks = require('../utils/githooks');
|
|
33
34
|
const LspInstaller = require('../utils/lsp-installer');
|
|
34
35
|
const UpdateNotifier = require('../utils/update-notifier');
|
|
35
36
|
const cliPackageJson = require('../../package.json');
|
|
@@ -234,6 +235,16 @@ async function detectState(cwd, opts = {}) {
|
|
|
234
235
|
}
|
|
235
236
|
} catch (_) {}
|
|
236
237
|
|
|
238
|
+
// ---- Git-hooks health (since v3.34.0) ------------------------------
|
|
239
|
+
// Assert that the repo's versioned git hooks (pre-commit/pre-push/…) are
|
|
240
|
+
// actually being run — i.e. `core.hooksPath` resolves to the directory
|
|
241
|
+
// that holds them. A drift here (e.g. core.hooksPath=.git/hooks while the
|
|
242
|
+
// hooks live in scripts/git-hooks/) silently disables every hook. Distinct
|
|
243
|
+
// from the BALDART `.claude/settings.json` hooks probed above. Fully
|
|
244
|
+
// fail-safe: `checked:false` when no managed hooks exist or on any error.
|
|
245
|
+
state.gitHooks = { checked: false, ok: true };
|
|
246
|
+
try { state.gitHooks = GitHooks.getStatus(cwd); } catch (_) {}
|
|
247
|
+
|
|
237
248
|
// ---- Agent symlink integrity (since v3.20.0) -----------------------
|
|
238
249
|
// Source of truth for the BALDART agent list is the framework filesystem
|
|
239
250
|
// directory itself (`.framework/framework/.claude/agents/*.md`). For each
|
|
@@ -503,6 +514,28 @@ function planActions(state) {
|
|
|
503
514
|
});
|
|
504
515
|
}
|
|
505
516
|
|
|
517
|
+
// Git-hooks health (since v3.34.0). ADVISORY ONLY — the action's run() never
|
|
518
|
+
// mutates `core.hooksPath` (a wrong heuristic + auto-fix is worse than the
|
|
519
|
+
// original silent-inactive bug). It surfaces the WARN + the exact remediation
|
|
520
|
+
// command so the user applies it deliberately. Present as an action (rather
|
|
521
|
+
// than a bare table WARN) so `doctor` doesn't contradict it with "Nothing to
|
|
522
|
+
// do". Safe to run in --auto since it only prints.
|
|
523
|
+
if (state.gitHooks && state.gitHooks.checked && !state.gitHooks.ok) {
|
|
524
|
+
actions.push({
|
|
525
|
+
key: 'githooks-health',
|
|
526
|
+
label: state.gitHooks.inactive
|
|
527
|
+
? `Git hooks INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} are not being run`
|
|
528
|
+
: `Git hooks not executable — ${state.gitHooks.nonExecutable.join(', ')}`,
|
|
529
|
+
why: `${state.gitHooks.detail}. Fix: ${state.gitHooks.fixCommand}`,
|
|
530
|
+
autoOk: true, // advisory print-only; run() does NOT mutate config
|
|
531
|
+
run: () => {
|
|
532
|
+
UI.warning(state.gitHooks.detail);
|
|
533
|
+
UI.info('Run to re-activate the repo\'s versioned git hooks:');
|
|
534
|
+
console.log(` ${state.gitHooks.fixCommand}`);
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
506
539
|
// Agent symlink integrity (since v3.20.0). Re-runs the agent merge loop to
|
|
507
540
|
// recreate any missing per-agent symlink. Calls `SymlinkUtils.mergeAgents`
|
|
508
541
|
// directly rather than dispatching to `update` — we want only the symlink
|
|
@@ -534,14 +567,23 @@ function planActions(state) {
|
|
|
534
567
|
actions.push({
|
|
535
568
|
key: 'lsp-fix',
|
|
536
569
|
label: `Reinstall ${state.lspBroken.length} broken LSP server(s)`,
|
|
537
|
-
why: `LSP layer is enabled but these language servers are not reachable: ${state.lspBroken.join(', ')}.
|
|
538
|
-
autoOk: false, //
|
|
539
|
-
// have made deliberate (switching from npm pyright to system pyright);
|
|
570
|
+
why: `LSP layer is enabled but these language servers are not reachable on $PATH: ${state.lspBroken.join(', ')}. The Claude Code LSP tool spawns each server by name from $PATH, so a binary that is missing OR only present in node_modules/.bin (a legacy devDep install) makes symbol search silently fall back to Grep. Reinstalling runs the global install so the binary lands on $PATH.`,
|
|
571
|
+
autoOk: false, // runs `npm install -g` and is the kind of action a user
|
|
572
|
+
// may have made deliberate (switching from npm pyright to system pyright);
|
|
540
573
|
// keep the outer prompt + the per-server confirm both alive.
|
|
541
574
|
run: async () => {
|
|
542
575
|
const installer = new LspInstaller(state.cwd);
|
|
543
576
|
const { installed, manual, skipped } = await installer.installServers(state.lspBroken);
|
|
544
|
-
if (installed.length)
|
|
577
|
+
if (installed.length) {
|
|
578
|
+
UI.success(`Reinstalled: ${installed.map(i => i.name).join(', ')}`);
|
|
579
|
+
// If a reinstall keeps coming back broken, npm's global bin dir is
|
|
580
|
+
// likely not on $PATH — surface the escape so the user doesn't loop.
|
|
581
|
+
const recheck = installer.verifyServers(installed.map(i => i.name));
|
|
582
|
+
if (recheck.some(r => !r.ok)) {
|
|
583
|
+
UI.warning('Some servers are still unreachable after reinstall.');
|
|
584
|
+
UI.info('Check that npm\'s global bin dir is on $PATH: `npm prefix -g` → add its `/bin` to PATH.');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
545
587
|
for (const m of manual) {
|
|
546
588
|
UI.warning(`System-level reinstall required for ${m.label}:`);
|
|
547
589
|
console.log(` ${m.command}`);
|
|
@@ -778,6 +820,29 @@ function renderDiagnostic(state) {
|
|
|
778
820
|
console.log(` • pre-v3.18: ${state.hooksLegacy.join(', ')}`);
|
|
779
821
|
}
|
|
780
822
|
|
|
823
|
+
// Git-hooks health (v3.34.0+). Silent when no versioned-hooks dir exists
|
|
824
|
+
// (`checked:false`) — most consumers have no managed git hooks and must see
|
|
825
|
+
// zero noise here.
|
|
826
|
+
if (state.gitHooks && state.gitHooks.checked) {
|
|
827
|
+
if (state.gitHooks.ok) {
|
|
828
|
+
console.log(statusLine(
|
|
829
|
+
'Git hooks',
|
|
830
|
+
`${state.gitHooks.source} active (${state.gitHooks.expectedHooks.join(', ')})`,
|
|
831
|
+
'ok'
|
|
832
|
+
));
|
|
833
|
+
} else if (state.gitHooks.inactive) {
|
|
834
|
+
console.log(statusLine(
|
|
835
|
+
'Git hooks',
|
|
836
|
+
`INACTIVE — ${state.gitHooks.source} hooks in ${state.gitHooks.expectedDir} not run by core.hooksPath`,
|
|
837
|
+
'warn'
|
|
838
|
+
));
|
|
839
|
+
console.log(` • fix: ${state.gitHooks.fixCommand}`);
|
|
840
|
+
} else {
|
|
841
|
+
console.log(statusLine('Git hooks', state.gitHooks.detail, 'warn'));
|
|
842
|
+
console.log(` • fix: ${state.gitHooks.fixCommand}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
781
846
|
if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
|
|
782
847
|
console.log(statusLine(
|
|
783
848
|
'.gitignore',
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fixture-driven test for the git-hooks active-vs-expected decision.
|
|
4
|
+
*
|
|
5
|
+
* Run: node src/utils/__tests__/githooks-active.test.js
|
|
6
|
+
* Exits 0 on success, 1 on any mismatch (CI-friendly).
|
|
7
|
+
*
|
|
8
|
+
* `isActiveServing` is the one decision that, if wrong, silently invalidates
|
|
9
|
+
* the entire git-hooks health check (src/utils/githooks.js). It is pure (path
|
|
10
|
+
* logic only) so it is fully unit-testable without a live repo — covering the
|
|
11
|
+
* husky-version branches that fs/git probing can't easily exercise.
|
|
12
|
+
*/
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { isActiveServing } = require('../githooks');
|
|
15
|
+
|
|
16
|
+
// Absolute roots so the fixtures don't depend on cwd. Normalize through
|
|
17
|
+
// path.resolve the same way the implementation does.
|
|
18
|
+
const ROOT = path.resolve('/repo');
|
|
19
|
+
const j = (...p) => path.join(ROOT, ...p);
|
|
20
|
+
|
|
21
|
+
const FIXTURES = [
|
|
22
|
+
// ─── the mayo bug: core.hooksPath=.git/hooks, hooks in scripts/git-hooks ──
|
|
23
|
+
{ active: j('.git', 'hooks'), expected: j('scripts', 'git-hooks'), want: false, note: 'mayo: inactive' },
|
|
24
|
+
|
|
25
|
+
// ─── husky v9: active=.husky/_ wrappers, user hooks in .husky ─────────────
|
|
26
|
+
{ active: j('.husky', '_'), expected: j('.husky'), want: true, note: 'husky v9 nested' },
|
|
27
|
+
// ─── husky v5-v8: core.hooksPath=.husky, hooks in .husky ──────────────────
|
|
28
|
+
{ active: j('.husky'), expected: j('.husky'), want: true, note: 'husky v8 identical' },
|
|
29
|
+
|
|
30
|
+
// ─── scripts convention, correctly wired ─────────────────────────────────
|
|
31
|
+
{ active: j('scripts', 'git-hooks'), expected: j('scripts', 'git-hooks'), want: true, note: 'scripts identical' },
|
|
32
|
+
|
|
33
|
+
// ─── .githooks convention, wired to default .git/hooks (inactive) ─────────
|
|
34
|
+
{ active: j('.git', 'hooks'), expected: j('.githooks'), want: false, note: '.githooks inactive' },
|
|
35
|
+
|
|
36
|
+
// ─── reverse nesting (expected within active) also counts as serving ──────
|
|
37
|
+
{ active: j('.husky'), expected: j('.husky', '_'), want: true, note: 'reverse nesting' },
|
|
38
|
+
|
|
39
|
+
// ─── sibling dirs sharing a prefix must NOT match (prefix-not-boundary) ───
|
|
40
|
+
{ active: j('hooks'), expected: j('hooks-extra'), want: false, note: 'prefix not a path boundary' },
|
|
41
|
+
|
|
42
|
+
// ─── trailing-slash / non-normalized inputs normalize equal ──────────────
|
|
43
|
+
{ active: j('.husky') + path.sep, expected: j('.husky'), want: true, note: 'trailing sep normalized' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
let failures = 0;
|
|
47
|
+
for (const f of FIXTURES) {
|
|
48
|
+
const got = isActiveServing(f.active, f.expected);
|
|
49
|
+
const ok = got === f.want;
|
|
50
|
+
if (!ok) {
|
|
51
|
+
failures++;
|
|
52
|
+
console.error(`✗ ${f.note}: isActiveServing(${f.active}, ${f.expected}) = ${got}, expected ${f.want}`);
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`✓ ${f.note}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (failures) {
|
|
59
|
+
console.error(`\n${failures} failure(s).`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
console.log(`\nAll ${FIXTURES.length} fixtures passed.`);
|
|
63
|
+
process.exit(0);
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-hooks health check — **assert-active**, not read-only.
|
|
3
|
+
*
|
|
4
|
+
* NOT to be confused with `src/utils/hooks.js`: that module manages the Claude
|
|
5
|
+
* Code hooks registered in `.claude/settings.json`. THIS module inspects the
|
|
6
|
+
* repo's own *git* hooks (pre-commit, pre-push, …) and asserts they are
|
|
7
|
+
* actually being executed by git — i.e. that `core.hooksPath` resolves to the
|
|
8
|
+
* directory where the repo's versioned hooks live.
|
|
9
|
+
*
|
|
10
|
+
* **Why it exists** (since v3.34.0)
|
|
11
|
+
*
|
|
12
|
+
* A consumer had `core.hooksPath` pointing at `.git/hooks` (empty) while the
|
|
13
|
+
* repo's real, versioned hooks lived in `scripts/git-hooks/`. Result: the
|
|
14
|
+
* pre-commit schema guard and pre-push were silently inactive for days, and
|
|
15
|
+
* the worktree-manager verify step (`git config core.hooksPath || true`) only
|
|
16
|
+
* *read* the value without asserting anything — so it never caught the drift.
|
|
17
|
+
* Same class as the LSP bug: the framework "verifies" by reading a proxy
|
|
18
|
+
* instead of asserting the real state. This moves it from read-only to
|
|
19
|
+
* assert-active.
|
|
20
|
+
*
|
|
21
|
+
* **Resolution of the active hooks dir** (the crux)
|
|
22
|
+
*
|
|
23
|
+
* `git rev-parse --git-path hooks` is NOT used: empirically it returns the
|
|
24
|
+
* configured value *relative and unresolved*, and historically some git
|
|
25
|
+
* versions ignored `core.hooksPath` for `--git-path` entirely. Instead we
|
|
26
|
+
* resolve explicitly:
|
|
27
|
+
* - `core.hooksPath` set → relative resolves against the working-tree
|
|
28
|
+
* top (`git rev-parse --show-toplevel`),
|
|
29
|
+
* absolute is used verbatim.
|
|
30
|
+
* - `core.hooksPath` unset → `<git rev-parse --git-dir>/hooks` (handles
|
|
31
|
+
* the worktree case where `.git` is a file).
|
|
32
|
+
*
|
|
33
|
+
* **Design invariants**
|
|
34
|
+
*
|
|
35
|
+
* - Fail-safe: every probe is wrapped; any internal error yields
|
|
36
|
+
* `{ checked: false, ok: true }` so the check NEVER blocks legitimate work
|
|
37
|
+
* and never produces a false WARN.
|
|
38
|
+
* - Silent when nothing to assert: a repo with no versioned-hooks directory
|
|
39
|
+
* returns `checked: false` (most consumers have no managed hooks).
|
|
40
|
+
* - No config flag: this is a universal, always-on health check auto-detected
|
|
41
|
+
* from the filesystem, so the schema-change-propagation rule does not apply.
|
|
42
|
+
* - WARN only: this module never mutates `core.hooksPath`. It reports a
|
|
43
|
+
* `fixCommand` string; applying it is the caller's (user's) decision.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
const { execFileSync } = require('child_process');
|
|
49
|
+
|
|
50
|
+
// Standard git hook filenames. A managed-hooks directory is recognized by
|
|
51
|
+
// containing at least one of these (husky places them at `.husky/<name>`,
|
|
52
|
+
// raw dirs at `<dir>/<name>`).
|
|
53
|
+
const STANDARD_HOOKS = [
|
|
54
|
+
'pre-commit', 'pre-push', 'commit-msg', 'prepare-commit-msg',
|
|
55
|
+
'pre-rebase', 'pre-merge-commit', 'post-checkout', 'post-commit',
|
|
56
|
+
'post-merge', 'post-rewrite', 'pre-applypatch', 'applypatch-msg',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Candidate versioned-hooks directories, in priority order. The first that
|
|
60
|
+
// exists AND contains a standard-named hook wins. `scripts/git-hooks` and
|
|
61
|
+
// `githooks` are conventions (not universal) — we enumerate a small known set
|
|
62
|
+
// rather than guess.
|
|
63
|
+
const MANAGED_DIRS = [
|
|
64
|
+
{ dir: '.husky', source: 'husky' },
|
|
65
|
+
{ dir: '.githooks', source: 'githooks' },
|
|
66
|
+
{ dir: 'scripts/git-hooks', source: 'scripts' },
|
|
67
|
+
{ dir: 'githooks', source: 'githooks' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function git(cwd, args) {
|
|
71
|
+
return execFileSync('git', args, {
|
|
72
|
+
cwd,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
+
}).trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find the repo's versioned-hooks directory, or null when none is managed.
|
|
80
|
+
* @returns {{ dir: string, abs: string, source: string, hooks: string[] } | null}
|
|
81
|
+
*/
|
|
82
|
+
function detectExpected(cwd) {
|
|
83
|
+
for (const cand of MANAGED_DIRS) {
|
|
84
|
+
const abs = path.join(cwd, cand.dir);
|
|
85
|
+
let names = [];
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.statSync(abs).isDirectory()) continue;
|
|
88
|
+
names = fs.readdirSync(abs).filter((f) => STANDARD_HOOKS.includes(f));
|
|
89
|
+
} catch (_) { continue; }
|
|
90
|
+
if (names.length) return { dir: cand.dir, abs, source: cand.source, hooks: names };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the directory git will actually look in for hooks.
|
|
97
|
+
* @returns {{ configured: string | null, abs: string }}
|
|
98
|
+
*/
|
|
99
|
+
function resolveActiveDir(cwd) {
|
|
100
|
+
let configured = null;
|
|
101
|
+
try { configured = git(cwd, ['config', '--get', 'core.hooksPath']) || null; } catch (_) {}
|
|
102
|
+
|
|
103
|
+
if (configured) {
|
|
104
|
+
if (path.isAbsolute(configured)) return { configured, abs: configured };
|
|
105
|
+
// Relative core.hooksPath resolves against the working-tree top, not cwd.
|
|
106
|
+
let top = cwd;
|
|
107
|
+
try { top = git(cwd, ['rev-parse', '--show-toplevel']); } catch (_) {}
|
|
108
|
+
return { configured, abs: path.resolve(top, configured) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Unset → <git-dir>/hooks. git-dir may be relative or a worktree gitdir.
|
|
112
|
+
let gitDir;
|
|
113
|
+
try { gitDir = git(cwd, ['rev-parse', '--git-dir']); } catch (_) { gitDir = path.join(cwd, '.git'); }
|
|
114
|
+
if (!path.isAbsolute(gitDir)) gitDir = path.resolve(cwd, gitDir);
|
|
115
|
+
return { configured: null, abs: path.join(gitDir, 'hooks') };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a path to its canonical (symlink-free) form so two paths computed
|
|
120
|
+
* from different bases compare correctly. The active dir (from git's
|
|
121
|
+
* realpath-resolved toplevel/git-dir) and the expected dir (from cwd) can
|
|
122
|
+
* otherwise differ purely by a symlinked ancestor — e.g. macOS `/var` →
|
|
123
|
+
* `/private/var`, or a symlinked repo/home path — yielding a false INACTIVE.
|
|
124
|
+
*
|
|
125
|
+
* Handles non-existent leaves (the active `.git/hooks` may not exist on a
|
|
126
|
+
* misconfigured repo): realpath the deepest existing ancestor, re-append the
|
|
127
|
+
* rest. Falls back to a plain resolve if nothing along the path is realpath-able.
|
|
128
|
+
*/
|
|
129
|
+
function canonicalize(p) {
|
|
130
|
+
let dir = path.resolve(p);
|
|
131
|
+
const tail = [];
|
|
132
|
+
for (;;) {
|
|
133
|
+
try {
|
|
134
|
+
const real = fs.realpathSync(dir);
|
|
135
|
+
return tail.length ? path.join(real, ...tail) : real;
|
|
136
|
+
} catch (_) {
|
|
137
|
+
const parent = path.dirname(dir);
|
|
138
|
+
if (parent === dir) return path.resolve(p); // reached fs root
|
|
139
|
+
tail.unshift(path.basename(dir));
|
|
140
|
+
dir = parent;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* PURE decision: is the active hooks dir serving the versioned hooks?
|
|
147
|
+
*
|
|
148
|
+
* True when the two dirs are identical OR one is nested within the other.
|
|
149
|
+
* The nesting case covers husky v9, which sets `core.hooksPath=.husky/_`
|
|
150
|
+
* (wrappers) while the user hooks live in `.husky/`. Exported for unit tests —
|
|
151
|
+
* this is the one decision that, if wrong, silently invalidates the whole check.
|
|
152
|
+
*/
|
|
153
|
+
function isActiveServing(activeAbs, expectedAbs) {
|
|
154
|
+
const a = path.resolve(activeAbs);
|
|
155
|
+
const e = path.resolve(expectedAbs);
|
|
156
|
+
if (a === e) return true;
|
|
157
|
+
return a.startsWith(e + path.sep) || e.startsWith(a + path.sep);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Inspect the repo's git-hooks health. Fail-safe.
|
|
162
|
+
* @returns {{
|
|
163
|
+
* checked: boolean, ok: boolean, source: string|null, configured: string|null,
|
|
164
|
+
* activeDir: string|null, expectedDir: string|null, expectedHooks: string[],
|
|
165
|
+
* inactive: boolean, nonExecutable: string[], detail: string, fixCommand: string|null
|
|
166
|
+
* }}
|
|
167
|
+
*/
|
|
168
|
+
function getStatus(cwd = process.cwd()) {
|
|
169
|
+
const result = {
|
|
170
|
+
checked: false,
|
|
171
|
+
ok: true,
|
|
172
|
+
source: null,
|
|
173
|
+
configured: null,
|
|
174
|
+
activeDir: null,
|
|
175
|
+
expectedDir: null,
|
|
176
|
+
expectedHooks: [],
|
|
177
|
+
inactive: false,
|
|
178
|
+
nonExecutable: [],
|
|
179
|
+
detail: '',
|
|
180
|
+
fixCommand: null,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const expected = detectExpected(cwd);
|
|
185
|
+
if (!expected) return result; // no managed hooks → silent, ok
|
|
186
|
+
|
|
187
|
+
const active = resolveActiveDir(cwd);
|
|
188
|
+
result.checked = true;
|
|
189
|
+
result.source = expected.source;
|
|
190
|
+
result.configured = active.configured;
|
|
191
|
+
result.activeDir = active.abs;
|
|
192
|
+
result.expectedDir = expected.dir;
|
|
193
|
+
result.expectedHooks = expected.hooks;
|
|
194
|
+
|
|
195
|
+
// Canonicalize both ends before comparing — they're computed from different
|
|
196
|
+
// bases (git realpath vs cwd) and a symlinked ancestor would otherwise
|
|
197
|
+
// produce a false INACTIVE.
|
|
198
|
+
if (!isActiveServing(canonicalize(active.abs), canonicalize(expected.abs))) {
|
|
199
|
+
result.inactive = true;
|
|
200
|
+
result.ok = false;
|
|
201
|
+
result.fixCommand = `git config core.hooksPath ${expected.dir}`;
|
|
202
|
+
result.detail = `core.hooksPath risolve a ${active.abs} ma gli hook versionati sono in ${expected.dir}`;
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Executability assert — only for raw dirs git execs directly. Husky sources
|
|
207
|
+
// user hooks via its `_` wrappers and v9 does NOT require them to be +x, so
|
|
208
|
+
// asserting -x there would manufacture false positives.
|
|
209
|
+
if (expected.source !== 'husky') {
|
|
210
|
+
for (const h of expected.hooks) {
|
|
211
|
+
try { fs.accessSync(path.join(expected.abs, h), fs.constants.X_OK); }
|
|
212
|
+
catch (_) { result.nonExecutable.push(h); }
|
|
213
|
+
}
|
|
214
|
+
if (result.nonExecutable.length) {
|
|
215
|
+
result.ok = false;
|
|
216
|
+
result.detail = `hook non eseguibili (manca il bit -x): ${result.nonExecutable.join(', ')}`;
|
|
217
|
+
result.fixCommand = `chmod +x ${result.nonExecutable.map((h) => path.join(expected.dir, h)).join(' ')}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result;
|
|
222
|
+
} catch (_) {
|
|
223
|
+
// Never block doctor / worktree verify on a probe failure.
|
|
224
|
+
return { ...result, checked: false, ok: true };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { getStatus, isActiveServing };
|
|
229
|
+
// Exposed for completeness / advanced callers and unit tests.
|
|
230
|
+
module.exports.detectExpected = detectExpected;
|
|
231
|
+
module.exports.resolveActiveDir = resolveActiveDir;
|
|
232
|
+
module.exports.STANDARD_HOOKS = STANDARD_HOOKS;
|
|
233
|
+
module.exports.MANAGED_DIRS = MANAGED_DIRS;
|
|
@@ -4,10 +4,12 @@ const path = require('path');
|
|
|
4
4
|
/**
|
|
5
5
|
* Python LSP adapter — Pyright.
|
|
6
6
|
*
|
|
7
|
-
* Pyright ships as both an npm package and a pip package. We default to
|
|
8
|
-
* npm
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Pyright ships as both an npm package and a pip package. We default to a
|
|
8
|
+
* global npm install so the `pyright` binary lands on `$PATH` — the consumer
|
|
9
|
+
* (Claude Code's LSP tool) spawns it by name from `$PATH`, so a project-local
|
|
10
|
+
* devDep under `node_modules/.bin/` (the pre-v3.33.1 behaviour) was never
|
|
11
|
+
* reachable and the layer silently fell back to Grep. Users who already drive
|
|
12
|
+
* Python via pip/uv can override with an overlay and a system-managed pyright.
|
|
11
13
|
*
|
|
12
14
|
* Detection signal: pyproject.toml, setup.py, setup.cfg, requirements.txt,
|
|
13
15
|
* Pipfile, or a top-level .py source tree.
|
|
@@ -18,11 +20,13 @@ class PythonAdapter {
|
|
|
18
20
|
get name() { return 'python'; }
|
|
19
21
|
get label() { return 'Python (Pyright)'; }
|
|
20
22
|
get binary() { return 'pyright'; }
|
|
21
|
-
get installMode() { return 'npm-
|
|
23
|
+
get installMode() { return 'npm-global'; }
|
|
22
24
|
get npmPackage() { return 'pyright'; }
|
|
23
25
|
|
|
24
|
-
installCommand() { return 'npm install
|
|
25
|
-
|
|
26
|
+
installCommand() { return 'npm install -g pyright'; }
|
|
27
|
+
// Functional probe; reachability on $PATH is checked by the installer via
|
|
28
|
+
// `command -v` (mirrors how the consumer spawns the binary).
|
|
29
|
+
verifyCommand() { return 'pyright --version'; }
|
|
26
30
|
claudePluginId() { return 'pyright-lsp@claude-plugins-official'; }
|
|
27
31
|
|
|
28
32
|
static detect(cwd = process.cwd()) {
|
|
@@ -5,8 +5,13 @@ const path = require('path');
|
|
|
5
5
|
* TypeScript / JavaScript LSP adapter.
|
|
6
6
|
*
|
|
7
7
|
* Language server: `typescript-language-server` (Node, npm).
|
|
8
|
-
* Install mode:
|
|
9
|
-
*
|
|
8
|
+
* Install mode: global npm install — the binary MUST land on `$PATH` because
|
|
9
|
+
* the consumer (Claude Code's LSP tool) spawns it by name from `$PATH`, not
|
|
10
|
+
* via `npx`/node_modules resolution. A project-local devDep (the pre-v3.33.1
|
|
11
|
+
* behaviour) installed into `node_modules/.bin/` which is NOT on `$PATH`, so
|
|
12
|
+
* every LSP spawn failed with `Executable not found in $PATH` while the
|
|
13
|
+
* `npx --no-install` verify falsely reported success — a dead layer masked by
|
|
14
|
+
* the silent Grep fallback.
|
|
10
15
|
*
|
|
11
16
|
* Detection signal: presence of `tsconfig.json`, `jsconfig.json`, or a
|
|
12
17
|
* `package.json` referencing typescript in deps/devDeps.
|
|
@@ -18,19 +23,29 @@ class TypeScriptAdapter {
|
|
|
18
23
|
get label() { return 'TypeScript / JavaScript'; }
|
|
19
24
|
get binary() { return 'typescript-language-server'; }
|
|
20
25
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 'npm-global' = installed globally via npm so it lands on `$PATH`.
|
|
28
|
+
* 'system' = printed install instructions (non-npm toolchains).
|
|
29
|
+
* Treated identically to system mode by the installer except that npm-global
|
|
30
|
+
* adapters are safe to auto-execute (no sudo, no toolchain assumptions).
|
|
31
|
+
*/
|
|
32
|
+
get installMode() { return 'npm-global'; }
|
|
23
33
|
|
|
24
|
-
/** npm package to install when mode='npm-
|
|
34
|
+
/** npm package(s) to install when mode='npm-global'. */
|
|
25
35
|
get npmPackage() { return 'typescript-language-server typescript'; }
|
|
26
36
|
|
|
27
37
|
/** Shell command the user (or installer) should run. */
|
|
28
38
|
installCommand() {
|
|
29
|
-
return 'npm install
|
|
39
|
+
return 'npm install -g typescript-language-server typescript';
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Functional probe: invokes the binary the SAME way the consumer spawns it —
|
|
44
|
+
* by name from `$PATH`. Reachability on `$PATH` is the actual contract the
|
|
45
|
+
* consumer depends on; `LspInstaller.verifyServers()` checks it explicitly
|
|
46
|
+
* via `command -v` and only runs this probe once the binary resolves.
|
|
47
|
+
*/
|
|
48
|
+
verifyCommand() { return 'typescript-language-server --version'; }
|
|
34
49
|
|
|
35
50
|
/**
|
|
36
51
|
* Claude Code code-intelligence plugin id, if any. Null = no companion plugin.
|
|
@@ -63,7 +63,7 @@ class LspInstaller {
|
|
|
63
63
|
/**
|
|
64
64
|
* Build a recommendation list from detected languages. Each entry carries
|
|
65
65
|
* the install command and the install mode so the UI can decide whether
|
|
66
|
-
* to run it automatically (npm-
|
|
66
|
+
* to run it automatically (npm-global) or just print it (system).
|
|
67
67
|
*/
|
|
68
68
|
recommend(langs = this.detectLanguages()) {
|
|
69
69
|
return langs.map(name => {
|
|
@@ -82,7 +82,8 @@ class LspInstaller {
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Install one or more language servers. Returns `{ installed, skipped, manual }`.
|
|
85
|
-
* - npm-
|
|
85
|
+
* - npm-global adapters: executed in-process (npm install -g …) so the
|
|
86
|
+
* binary lands on `$PATH` where the consumer can spawn it.
|
|
86
87
|
* - system adapters: never executed automatically; surfaced under `manual`
|
|
87
88
|
* so the caller can print the command for the user to run.
|
|
88
89
|
*
|
|
@@ -335,25 +336,96 @@ class LspInstaller {
|
|
|
335
336
|
}
|
|
336
337
|
|
|
337
338
|
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
339
|
+
* Is `binary` resolvable on `$PATH`? This mirrors EXACTLY how the consumer
|
|
340
|
+
* (Claude Code's LSP tool) spawns the language server — by name, from
|
|
341
|
+
* `$PATH`. Using `npx --no-install` here instead would resolve from
|
|
342
|
+
* `node_modules/.bin/` and produce a false positive: the verify passes while
|
|
343
|
+
* every real LSP spawn fails with `Executable not found in $PATH`. That
|
|
344
|
+
* resolution mismatch was the root cause of the pre-v3.33.1 dead-layer bug.
|
|
345
|
+
*/
|
|
346
|
+
_isOnPath(binary) {
|
|
347
|
+
try {
|
|
348
|
+
execSync(`command -v ${binary}`, {
|
|
349
|
+
cwd: this.cwd,
|
|
350
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
351
|
+
timeout: 5000,
|
|
352
|
+
shell: '/bin/sh'
|
|
353
|
+
});
|
|
354
|
+
return true;
|
|
355
|
+
} catch { return false; }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* For npm-managed adapters: is the binary installed project-locally (in
|
|
360
|
+
* `node_modules/.bin/`) even though it isn't on `$PATH`? This is the precise
|
|
361
|
+
* fingerprint of the legacy `npm install --save-dev` install — present, but
|
|
362
|
+
* unreachable by the consumer. System adapters have no local notion → false.
|
|
363
|
+
*/
|
|
364
|
+
_isInstalledLocally(adapter) {
|
|
365
|
+
if (adapter.installMode === 'system') return false;
|
|
366
|
+
try {
|
|
367
|
+
execSync(`npx --no-install ${adapter.binary} --version`, {
|
|
368
|
+
cwd: this.cwd,
|
|
369
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
370
|
+
timeout: 8000
|
|
371
|
+
});
|
|
372
|
+
return true;
|
|
373
|
+
} catch { return false; }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Verify each named server is reachable the way the consumer spawns it.
|
|
378
|
+
* Returns `[{ name, ok, status, output? , error?, fix? }]`. Never throws.
|
|
379
|
+
*
|
|
380
|
+
* `status` is one of:
|
|
381
|
+
* - `verified` — on `$PATH` AND the functional probe passed.
|
|
382
|
+
* - `installed-but-unreachable` — installed locally (node_modules) but not
|
|
383
|
+
* on `$PATH`; the consumer cannot spawn it. `fix` carries the command
|
|
384
|
+
* that puts it on `$PATH`. Treated as broken (`ok:false`) — same honesty
|
|
385
|
+
* bar already applied to system adapters whose binary isn't installed.
|
|
386
|
+
* - `unreachable` — on `$PATH` but the probe errored (corrupt
|
|
387
|
+
* install / wrong flag). Rare.
|
|
388
|
+
* - `not-installed` — neither on `$PATH` nor locally installed.
|
|
389
|
+
* - `unknown-adapter` — name not in the registry.
|
|
340
390
|
*/
|
|
341
391
|
verifyServers(serverNames) {
|
|
342
392
|
return serverNames.map(name => {
|
|
343
393
|
let a;
|
|
344
394
|
try { a = getAdapter(name, this.cwd); }
|
|
345
|
-
catch (err) { return { name, ok: false, error: err.message }; }
|
|
395
|
+
catch (err) { return { name, ok: false, status: 'unknown-adapter', error: err.message }; }
|
|
346
396
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
397
|
+
if (this._isOnPath(a.binary)) {
|
|
398
|
+
try {
|
|
399
|
+
const out = execSync(a.verifyCommand(), {
|
|
400
|
+
cwd: this.cwd,
|
|
401
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
402
|
+
timeout: 8000
|
|
403
|
+
}).toString().trim();
|
|
404
|
+
return { name, ok: true, status: 'verified', output: out };
|
|
405
|
+
} catch (err) {
|
|
406
|
+
return {
|
|
407
|
+
name, ok: false, status: 'unreachable',
|
|
408
|
+
error: (err.message || '').split('\n')[0],
|
|
409
|
+
fix: a.installCommand()
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Not on $PATH. Distinguish "installed locally but unreachable" (the
|
|
415
|
+
// documented false-positive scenario) from "not installed at all".
|
|
416
|
+
if (this._isInstalledLocally(a)) {
|
|
417
|
+
return {
|
|
418
|
+
name, ok: false, status: 'installed-but-unreachable',
|
|
419
|
+
error: `${a.binary} is installed locally but not on $PATH; the Claude Code LSP tool spawns it by name from $PATH`,
|
|
420
|
+
fix: a.installCommand()
|
|
421
|
+
};
|
|
356
422
|
}
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
name, ok: false, status: 'not-installed',
|
|
426
|
+
error: `${a.binary} not found on $PATH`,
|
|
427
|
+
fix: a.installCommand()
|
|
428
|
+
};
|
|
357
429
|
});
|
|
358
430
|
}
|
|
359
431
|
}
|