baldart 3.8.2 → 3.10.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 +61 -0
- package/README.md +6 -2
- package/VERSION +1 -1
- package/framework/.claude/agents/REGISTRY.md +1 -1
- package/framework/.claude/agents/codebase-architect.md +8 -0
- package/framework/.claude/skills/bug/SKILL.md +5 -1
- package/framework/.claude/skills/context-primer/SKILL.md +4 -0
- package/framework/.claude/skills/lsp-bootstrap/SKILL.md +113 -0
- package/framework/.claude/skills/new/SKILL.md +4 -4
- package/framework/.claude/skills/prd/SKILL.md +3 -1
- package/framework/.claude/skills/simplify/SKILL.md +6 -0
- package/framework/.claude/skills/worktree-manager/SKILL.md +91 -20
- package/framework/agents/code-search-protocol.md +126 -0
- package/framework/agents/index.md +2 -0
- package/framework/docs/LSP-LAYER.md +405 -0
- package/framework/docs/PROJECT-CONFIGURATION.md +17 -0
- package/framework/templates/baldart.config.template.yml +42 -0
- package/framework/templates/overlays/agents/codebase-architect.lsp-example.md +53 -0
- package/package.json +1 -1
- package/src/commands/configure.js +124 -1
- package/src/commands/doctor.js +55 -0
- package/src/commands/update.js +14 -5
- package/src/utils/lsp-adapters/go.js +29 -0
- package/src/utils/lsp-adapters/index.js +49 -0
- package/src/utils/lsp-adapters/python.js +42 -0
- package/src/utils/lsp-adapters/ruby.js +30 -0
- package/src/utils/lsp-adapters/rust.js +26 -0
- package/src/utils/lsp-adapters/typescript.js +54 -0
- package/src/utils/lsp-installer.js +118 -0
|
@@ -3,6 +3,8 @@ const path = require('path');
|
|
|
3
3
|
const yaml = require('js-yaml');
|
|
4
4
|
const UI = require('../utils/ui');
|
|
5
5
|
const toolAdapters = require('../utils/tool-adapters');
|
|
6
|
+
const LspInstaller = require('../utils/lsp-installer');
|
|
7
|
+
const lspAdapters = require('../utils/lsp-adapters');
|
|
6
8
|
|
|
7
9
|
const CONFIG_FILE = 'baldart.config.yml';
|
|
8
10
|
// The subtree pull copies the entire BALDART repo (which itself has a
|
|
@@ -26,6 +28,48 @@ const IGNORED_DIRS = new Set([
|
|
|
26
28
|
'.expo', '.parcel-cache', 'tmp', '.idea', '.vscode', '.DS_Store'
|
|
27
29
|
]);
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Probe origin/develop branch protection to recommend a merge strategy.
|
|
33
|
+
* Returns: { value, protected, reason }
|
|
34
|
+
* - 'pr' when develop is protected (PR is the only viable path)
|
|
35
|
+
* - 'local-push' when develop is NOT protected (FF push is faster, no gh needed)
|
|
36
|
+
* - 'pr' as a safe default if we can't tell (gh missing, network down, no remote)
|
|
37
|
+
*
|
|
38
|
+
* Non-fatal: any failure returns `{ value: 'pr', protected: null }` so configure
|
|
39
|
+
* keeps moving. The user can override in the interactive prompt.
|
|
40
|
+
*/
|
|
41
|
+
function detectMergeStrategy(cwd = process.cwd()) {
|
|
42
|
+
try {
|
|
43
|
+
const { execSync } = require('child_process');
|
|
44
|
+
const opts = { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, encoding: 'utf8' };
|
|
45
|
+
// Need both git + gh. If either is absent or unauthenticated, default 'pr'.
|
|
46
|
+
try { execSync('gh --version', opts); } catch (_) {
|
|
47
|
+
return { value: 'pr', protected: null, reason: 'gh CLI not installed — defaulting to pr.' };
|
|
48
|
+
}
|
|
49
|
+
let nameWithOwner = '';
|
|
50
|
+
try {
|
|
51
|
+
nameWithOwner = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', opts).trim();
|
|
52
|
+
} catch (_) {
|
|
53
|
+
return { value: 'pr', protected: null, reason: 'no GitHub remote detected — defaulting to pr.' };
|
|
54
|
+
}
|
|
55
|
+
// Probe branch protection. 404 means "not protected" → local-push viable.
|
|
56
|
+
try {
|
|
57
|
+
execSync(`gh api repos/${nameWithOwner}/branches/develop/protection`, opts);
|
|
58
|
+
return { value: 'pr', protected: true, reason: 'develop is protected on origin — PR strategy required.' };
|
|
59
|
+
} catch (_) {
|
|
60
|
+
// Could be 404 (not protected) OR branch doesn't exist. Check existence:
|
|
61
|
+
try {
|
|
62
|
+
execSync(`gh api repos/${nameWithOwner}/branches/develop -q .name`, opts);
|
|
63
|
+
return { value: 'local-push', protected: false, reason: 'develop exists on origin and is not protected — local-push viable.' };
|
|
64
|
+
} catch (_) {
|
|
65
|
+
return { value: 'pr', protected: null, reason: 'develop branch not found on origin — defaulting to pr.' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (_) {
|
|
69
|
+
return { value: 'pr', protected: null, reason: 'probe failed — defaulting to pr.' };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
29
73
|
function detect(cwd = process.cwd()) {
|
|
30
74
|
const exists = (p) => fs.existsSync(path.join(cwd, p));
|
|
31
75
|
const findFirst = (...candidates) => candidates.find(exists) || '';
|
|
@@ -238,6 +282,8 @@ function detect(cwd = process.cwd()) {
|
|
|
238
282
|
: (exists('cypress.config.ts') || exists('cypress.config.js') || hasDep('cypress')) ? 'cypress'
|
|
239
283
|
: '';
|
|
240
284
|
|
|
285
|
+
const mergeStrategyProbe = detectMergeStrategy(cwd);
|
|
286
|
+
|
|
241
287
|
const detected = {
|
|
242
288
|
paths: {
|
|
243
289
|
design_system: designSystemPath,
|
|
@@ -298,10 +344,23 @@ function detect(cwd = process.cwd()) {
|
|
|
298
344
|
has_adrs: countMatches('docs/decisions', /^ADR-.*\.md$/) > 0,
|
|
299
345
|
has_prd_workflow: exists('docs/prd'),
|
|
300
346
|
has_wiki_overlay: exists('docs/wiki'),
|
|
347
|
+
// LSP recommended when any supported language server adapter would
|
|
348
|
+
// detect this project. Drives the default for the configure prompt.
|
|
349
|
+
has_lsp_layer: lspAdapters.detectAll(cwd).length > 0,
|
|
301
350
|
},
|
|
302
351
|
tools: {
|
|
303
352
|
enabled: toolAdapters.defaultEnabled(cwd)
|
|
304
353
|
},
|
|
354
|
+
git: {
|
|
355
|
+
// Set to a plain string — the structured probe details (`protected`,
|
|
356
|
+
// `reason`) are kept on a sibling key so mergePreserving stays type-safe.
|
|
357
|
+
merge_strategy: mergeStrategyProbe.value,
|
|
358
|
+
},
|
|
359
|
+
_probes: {
|
|
360
|
+
// Internal scratchpad — never serialized to YAML. Used by
|
|
361
|
+
// interactivePrompts to surface the autodetection reasoning.
|
|
362
|
+
merge_strategy: mergeStrategyProbe,
|
|
363
|
+
},
|
|
305
364
|
};
|
|
306
365
|
|
|
307
366
|
// Reference walkFirst once so the dead-code linter is happy and the helper
|
|
@@ -424,6 +483,25 @@ async function interactivePrompts(merged, detected) {
|
|
|
424
483
|
}
|
|
425
484
|
merged.tools.enabled = currentEnabled;
|
|
426
485
|
|
|
486
|
+
// ---- Git merge strategy (since v3.9.0) --------------------------------
|
|
487
|
+
UI.section('Git merge strategy (how `/mw` lands feature branches onto develop)');
|
|
488
|
+
merged.git = merged.git || {};
|
|
489
|
+
const probe = (detected._probes && detected._probes.merge_strategy)
|
|
490
|
+
|| { value: 'pr', protected: null };
|
|
491
|
+
if (probe.reason) UI.info(probe.reason);
|
|
492
|
+
const currentMerge = merged.git.merge_strategy || probe.value || 'pr';
|
|
493
|
+
const chosenMerge = await UI.select(
|
|
494
|
+
`Choose merge strategy (current: ${currentMerge})`,
|
|
495
|
+
[
|
|
496
|
+
{ name: 'pr — open a GitHub PR, merge via `gh pr merge` (required if develop is protected)', value: 'pr' },
|
|
497
|
+
{ name: 'local-push — direct FF push to origin/develop (no PR, no `gh` dependency)', value: 'local-push' },
|
|
498
|
+
]
|
|
499
|
+
);
|
|
500
|
+
merged.git.merge_strategy = chosenMerge;
|
|
501
|
+
if (chosenMerge === 'local-push' && probe.protected === true) {
|
|
502
|
+
UI.warning('You picked `local-push` but develop appears protected on origin — pushes will be rejected. Switch back to `pr` if it fails.');
|
|
503
|
+
}
|
|
504
|
+
|
|
427
505
|
UI.section('Features (explicit yes/no — option A: always ask)');
|
|
428
506
|
for (const flag of [
|
|
429
507
|
['has_design_system', 'Project has a documented design system?'],
|
|
@@ -433,6 +511,7 @@ async function interactivePrompts(merged, detected) {
|
|
|
433
511
|
['has_adrs', 'Project uses ADR workflow?'],
|
|
434
512
|
['has_prd_workflow', 'Project uses PRD workflow (docs/prd/)?'],
|
|
435
513
|
['has_wiki_overlay', 'Project has LLM-wiki overlay (docs/wiki/)?'],
|
|
514
|
+
['has_lsp_layer', 'Enable LSP symbol-search layer? (recommended for large codebases)'],
|
|
436
515
|
]) {
|
|
437
516
|
const [key, question] = flag;
|
|
438
517
|
const currentVal = merged.features[key];
|
|
@@ -471,6 +550,48 @@ async function interactivePrompts(merged, detected) {
|
|
|
471
550
|
merged.paths[key] = await promptForKey(`${label}`, proposed);
|
|
472
551
|
}
|
|
473
552
|
|
|
553
|
+
// ---- LSP layer (since v3.10.0) ----------------------------------------
|
|
554
|
+
// Only runs when the user opted in above. Idempotent: re-running configure
|
|
555
|
+
// on an already-installed project re-detects, asks which servers to keep,
|
|
556
|
+
// and updates lsp.installed_servers accordingly.
|
|
557
|
+
merged.lsp = merged.lsp || { installed_servers: [], auto_verify: true };
|
|
558
|
+
if (merged.features.has_lsp_layer === true) {
|
|
559
|
+
UI.section('LSP layer (install language servers for symbol search)');
|
|
560
|
+
const installer = new LspInstaller(process.cwd());
|
|
561
|
+
const detectedLangs = installer.detectLanguages();
|
|
562
|
+
if (detectedLangs.length === 0) {
|
|
563
|
+
UI.warning('No supported languages detected (tsconfig.json, pyproject.toml, go.mod, Cargo.toml, Gemfile).');
|
|
564
|
+
UI.info('Skipping LSP install. You can run `/lsp-bootstrap` or `npx baldart configure` later.');
|
|
565
|
+
} else {
|
|
566
|
+
const recs = installer.recommend(detectedLangs);
|
|
567
|
+
const already = new Set(merged.lsp.installed_servers || []);
|
|
568
|
+
const toInstall = [];
|
|
569
|
+
for (const r of recs) {
|
|
570
|
+
const note = already.has(r.name) ? ' (already recorded)' : '';
|
|
571
|
+
UI.info(`${r.label} → ${r.binary} (${r.installMode})${note}`);
|
|
572
|
+
const want = await UI.confirm(`Install ${r.label}?`, true);
|
|
573
|
+
if (want) toInstall.push(r.name);
|
|
574
|
+
}
|
|
575
|
+
if (toInstall.length) {
|
|
576
|
+
const { installed, skipped, manual } = await installer.installServers(toInstall, { interactive: false });
|
|
577
|
+
for (const m of manual) {
|
|
578
|
+
UI.warning(`System-level install required for ${m.label}:`);
|
|
579
|
+
UI.code(m.command, `Recorded as pending — run \`baldart doctor\` after installing to verify.`);
|
|
580
|
+
}
|
|
581
|
+
const verify = installer.verifyServers(installed.map(i => i.name));
|
|
582
|
+
const verified = verify.filter(v => v.ok).map(v => v.name);
|
|
583
|
+
const newSet = new Set([...(merged.lsp.installed_servers || []), ...verified, ...manual.map(m => m.name)]);
|
|
584
|
+
merged.lsp.installed_servers = Array.from(newSet);
|
|
585
|
+
if (skipped.length) {
|
|
586
|
+
UI.warning(`Skipped: ${skipped.map(s => `${s.name} (${s.reason})`).join(', ')}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} else {
|
|
591
|
+
// Feature disabled — wipe the state so `installed_servers` doesn't lie.
|
|
592
|
+
merged.lsp.installed_servers = [];
|
|
593
|
+
}
|
|
594
|
+
|
|
474
595
|
UI.section('Stack (autodetected from package.json — confirm or override)');
|
|
475
596
|
const charting = merged.stack.charting;
|
|
476
597
|
const chartingCanonical = await promptForKey(
|
|
@@ -535,7 +656,9 @@ function serialize(config) {
|
|
|
535
656
|
# NOTE: features.* flags MUST be explicit (true | false). Any flag absent
|
|
536
657
|
# from this file will prompt the user on first skill invocation.
|
|
537
658
|
`;
|
|
538
|
-
|
|
659
|
+
const clean = stripNullFeatures(config);
|
|
660
|
+
if (clean && clean._probes) delete clean._probes;
|
|
661
|
+
return header + yaml.dump(clean, { lineWidth: 100, noRefs: true });
|
|
539
662
|
}
|
|
540
663
|
|
|
541
664
|
async function configure(opts = {}) {
|
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 LspInstaller = require('../utils/lsp-installer');
|
|
33
34
|
|
|
34
35
|
const FRAMEWORK_DIR = '.framework';
|
|
35
36
|
const REPO_DEFAULT = 'antbald/BALDART';
|
|
@@ -196,6 +197,25 @@ async function detectState(cwd, opts = {}) {
|
|
|
196
197
|
|
|
197
198
|
state.editGateRegistered = false;
|
|
198
199
|
try { state.editGateRegistered = Hooks.isRegistered(cwd); } catch (_) {}
|
|
200
|
+
|
|
201
|
+
// ---- LSP layer (since v3.10.0) -------------------------------------
|
|
202
|
+
state.lspEnabled = false;
|
|
203
|
+
state.lspInstalled = [];
|
|
204
|
+
state.lspBroken = [];
|
|
205
|
+
try {
|
|
206
|
+
// Reuse the `config` already parsed above instead of re-reading the file.
|
|
207
|
+
if (config && !config.__malformed && config.features && config.features.has_lsp_layer === true) {
|
|
208
|
+
state.lspEnabled = true;
|
|
209
|
+
const installed = (config.lsp && config.lsp.installed_servers) || [];
|
|
210
|
+
state.lspInstalled = installed;
|
|
211
|
+
const autoVerify = !(config.lsp && config.lsp.auto_verify === false);
|
|
212
|
+
if (autoVerify && installed.length) {
|
|
213
|
+
const installer = new LspInstaller(cwd);
|
|
214
|
+
const results = installer.verifyServers(installed);
|
|
215
|
+
state.lspBroken = results.filter(r => !r.ok).map(r => r.name);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch (_) { /* never block doctor on LSP probe */ }
|
|
199
219
|
}
|
|
200
220
|
|
|
201
221
|
return state;
|
|
@@ -306,6 +326,27 @@ function planActions(state) {
|
|
|
306
326
|
});
|
|
307
327
|
}
|
|
308
328
|
|
|
329
|
+
if (state.lspEnabled && state.lspBroken && state.lspBroken.length > 0) {
|
|
330
|
+
actions.push({
|
|
331
|
+
key: 'lsp-fix',
|
|
332
|
+
label: `Reinstall ${state.lspBroken.length} broken LSP server(s)`,
|
|
333
|
+
why: `LSP layer is enabled but these language servers are not reachable: ${state.lspBroken.join(', ')}. Symbol search will silently fall back to Grep until they are reinstalled.`,
|
|
334
|
+
autoOk: false, // touches dev-deps and is the kind of action a user may
|
|
335
|
+
// have made deliberate (switching from npm pyright to system pyright);
|
|
336
|
+
// keep the outer prompt + the per-server confirm both alive.
|
|
337
|
+
run: async () => {
|
|
338
|
+
const installer = new LspInstaller(state.cwd);
|
|
339
|
+
const { installed, manual, skipped } = await installer.installServers(state.lspBroken);
|
|
340
|
+
if (installed.length) UI.success(`Reinstalled: ${installed.map(i => i.name).join(', ')}`);
|
|
341
|
+
for (const m of manual) {
|
|
342
|
+
UI.warning(`System-level reinstall required for ${m.label}:`);
|
|
343
|
+
console.log(` ${m.command}`);
|
|
344
|
+
}
|
|
345
|
+
if (skipped.length) UI.warning(`Skipped: ${skipped.map(s => `${s.name} (${s.reason})`).join(', ')}`);
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
309
350
|
const remoteAhead = state.remote.fetched && state.remote.behind > 0;
|
|
310
351
|
const hasLocalWork = state.workingTreeDirty > 0 || state.commitsAhead > 0;
|
|
311
352
|
|
|
@@ -437,6 +478,20 @@ function renderDiagnostic(state) {
|
|
|
437
478
|
state.editGateRegistered ? 'ok' : 'warn'
|
|
438
479
|
));
|
|
439
480
|
|
|
481
|
+
if (state.lspEnabled) {
|
|
482
|
+
const total = state.lspInstalled.length;
|
|
483
|
+
const broken = state.lspBroken.length;
|
|
484
|
+
const value = total === 0
|
|
485
|
+
? 'enabled but no servers installed'
|
|
486
|
+
: broken === 0
|
|
487
|
+
? `${total} server(s) verified (${state.lspInstalled.join(', ')})`
|
|
488
|
+
: `${broken}/${total} broken (${state.lspBroken.join(', ')})`;
|
|
489
|
+
const severity = total === 0 || broken > 0 ? 'warn' : 'ok';
|
|
490
|
+
console.log(statusLine('LSP layer', value, severity));
|
|
491
|
+
} else {
|
|
492
|
+
console.log(statusLine('LSP layer', 'disabled', 'ok'));
|
|
493
|
+
}
|
|
494
|
+
|
|
440
495
|
console.log();
|
|
441
496
|
}
|
|
442
497
|
|
package/src/commands/update.js
CHANGED
|
@@ -413,12 +413,21 @@ async function update(options = {}) {
|
|
|
413
413
|
.filter((k) => !(k in (cur2.features || {})));
|
|
414
414
|
const missingPaths = Object.keys(tpl.paths || {})
|
|
415
415
|
.filter((k) => !(k in (cur2.paths || {})));
|
|
416
|
-
|
|
416
|
+
const missingGit = Object.keys(tpl.git || {})
|
|
417
|
+
.filter((k) => !(k in (cur2.git || {})));
|
|
418
|
+
const allMissing = [...missingPaths, ...missingFeatures, ...missingGit.map((k) => `git.${k}`)];
|
|
419
|
+
if (allMissing.length) {
|
|
417
420
|
UI.newline();
|
|
418
|
-
UI.warning(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
);
|
|
421
|
+
UI.warning(`New config keys in this version: ${allMissing.join(', ')}.`);
|
|
422
|
+
// Auto-offer configure so the user actually answers, instead of
|
|
423
|
+
// silently falling back to template defaults on first use.
|
|
424
|
+
const runConfigure = await UI.confirm('Run `baldart configure` now to populate them? (existing values are preserved)', true);
|
|
425
|
+
if (runConfigure) {
|
|
426
|
+
const configureCmd = require('./configure');
|
|
427
|
+
await configureCmd();
|
|
428
|
+
} else {
|
|
429
|
+
UI.info('Skipped. Skills will prompt for the missing keys on first use.');
|
|
430
|
+
}
|
|
422
431
|
}
|
|
423
432
|
} catch (_) {
|
|
424
433
|
// Non-fatal — schema drift detection is best-effort.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Go LSP adapter — gopls.
|
|
6
|
+
*
|
|
7
|
+
* gopls is a Go binary; we can't install it into node_modules. installMode
|
|
8
|
+
* is 'system' which means the configure flow prints the canonical install
|
|
9
|
+
* command and the user runs it themselves. The verify step still checks
|
|
10
|
+
* that the binary is in PATH.
|
|
11
|
+
*/
|
|
12
|
+
class GoAdapter {
|
|
13
|
+
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
14
|
+
|
|
15
|
+
get name() { return 'go'; }
|
|
16
|
+
get label() { return 'Go (gopls)'; }
|
|
17
|
+
get binary() { return 'gopls'; }
|
|
18
|
+
get installMode() { return 'system'; }
|
|
19
|
+
|
|
20
|
+
installCommand() { return 'go install golang.org/x/tools/gopls@latest'; }
|
|
21
|
+
verifyCommand() { return 'gopls version'; }
|
|
22
|
+
claudePluginId() { return null; }
|
|
23
|
+
|
|
24
|
+
static detect(cwd = process.cwd()) {
|
|
25
|
+
return fs.existsSync(path.join(cwd, 'go.mod'));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = GoAdapter;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const TypeScriptAdapter = require('./typescript');
|
|
2
|
+
const PythonAdapter = require('./python');
|
|
3
|
+
const GoAdapter = require('./go');
|
|
4
|
+
const RustAdapter = require('./rust');
|
|
5
|
+
const RubyAdapter = require('./ruby');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* LSP adapter registry.
|
|
9
|
+
*
|
|
10
|
+
* Adding a new language (Java, C/C++, PHP, Elixir, …):
|
|
11
|
+
* 1. Create `src/utils/lsp-adapters/<lang>.js` exporting a class with the
|
|
12
|
+
* same shape as TypeScriptAdapter (name, label, binary, installMode,
|
|
13
|
+
* installCommand, verifyCommand, claudePluginId, static detect).
|
|
14
|
+
* 2. Add it to the REGISTRY below.
|
|
15
|
+
* 3. (Optional) Wire a Claude Code plugin id if one exists.
|
|
16
|
+
*
|
|
17
|
+
* Adapters describe *how* to install/verify a language server; they do NOT
|
|
18
|
+
* speak LSP themselves. The actual symbol search at runtime happens through
|
|
19
|
+
* whatever Claude Code plugin or MCP server the user has loaded.
|
|
20
|
+
*/
|
|
21
|
+
const REGISTRY = {
|
|
22
|
+
typescript: TypeScriptAdapter,
|
|
23
|
+
python: PythonAdapter,
|
|
24
|
+
go: GoAdapter,
|
|
25
|
+
rust: RustAdapter,
|
|
26
|
+
ruby: RubyAdapter
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function listAdapters() { return Object.keys(REGISTRY); }
|
|
30
|
+
|
|
31
|
+
function getAdapter(name, cwd) {
|
|
32
|
+
const Cls = REGISTRY[name];
|
|
33
|
+
if (!Cls) throw new Error(`Unknown LSP adapter: ${name}. Available: ${listAdapters().join(', ')}`);
|
|
34
|
+
return new Cls(cwd);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Probe the cwd for every known language and return the names of adapters
|
|
39
|
+
* whose static detect() fires. Pure: no install side effects.
|
|
40
|
+
*/
|
|
41
|
+
function detectAll(cwd = process.cwd()) {
|
|
42
|
+
return Object.entries(REGISTRY)
|
|
43
|
+
.filter(([, Cls]) => {
|
|
44
|
+
try { return Cls.detect(cwd); } catch { return false; }
|
|
45
|
+
})
|
|
46
|
+
.map(([name]) => name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { REGISTRY, listAdapters, getAdapter, detectAll };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Python LSP adapter — Pyright.
|
|
6
|
+
*
|
|
7
|
+
* Pyright ships as both an npm package and a pip package. We default to the
|
|
8
|
+
* npm devDep route so installs stay project-local and version-pinned without
|
|
9
|
+
* touching the user's Python env. Users who already drive Python via pip/uv
|
|
10
|
+
* can override by removing pyright from devDeps and installing system-wide.
|
|
11
|
+
*
|
|
12
|
+
* Detection signal: pyproject.toml, setup.py, setup.cfg, requirements.txt,
|
|
13
|
+
* Pipfile, or a top-level .py source tree.
|
|
14
|
+
*/
|
|
15
|
+
class PythonAdapter {
|
|
16
|
+
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
17
|
+
|
|
18
|
+
get name() { return 'python'; }
|
|
19
|
+
get label() { return 'Python (Pyright)'; }
|
|
20
|
+
get binary() { return 'pyright'; }
|
|
21
|
+
get installMode() { return 'npm-dev'; }
|
|
22
|
+
get npmPackage() { return 'pyright'; }
|
|
23
|
+
|
|
24
|
+
installCommand() { return 'npm install --save-dev pyright'; }
|
|
25
|
+
verifyCommand() { return 'npx --no-install pyright --version'; }
|
|
26
|
+
claudePluginId() { return null; }
|
|
27
|
+
|
|
28
|
+
static detect(cwd = process.cwd()) {
|
|
29
|
+
const markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile'];
|
|
30
|
+
for (const m of markers) {
|
|
31
|
+
if (fs.existsSync(path.join(cwd, m))) return true;
|
|
32
|
+
}
|
|
33
|
+
// Fallback: a `src/` or top-level *.py file is a strong enough signal.
|
|
34
|
+
try {
|
|
35
|
+
const entries = fs.readdirSync(cwd);
|
|
36
|
+
if (entries.some(e => e.endsWith('.py'))) return true;
|
|
37
|
+
} catch { /* ignore */ }
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = PythonAdapter;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ruby LSP adapter — ruby-lsp (Shopify).
|
|
6
|
+
*
|
|
7
|
+
* Installed as a gem; system-mode. Users on Bundler-managed projects may
|
|
8
|
+
* prefer to add `gem 'ruby-lsp', group: :development` to their Gemfile and
|
|
9
|
+
* `bundle install` instead — we print the simple gem install command for
|
|
10
|
+
* portability.
|
|
11
|
+
*/
|
|
12
|
+
class RubyAdapter {
|
|
13
|
+
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
14
|
+
|
|
15
|
+
get name() { return 'ruby'; }
|
|
16
|
+
get label() { return 'Ruby (ruby-lsp)'; }
|
|
17
|
+
get binary() { return 'ruby-lsp'; }
|
|
18
|
+
get installMode() { return 'system'; }
|
|
19
|
+
|
|
20
|
+
installCommand() { return 'gem install ruby-lsp'; }
|
|
21
|
+
verifyCommand() { return 'ruby-lsp --version'; }
|
|
22
|
+
claudePluginId() { return null; }
|
|
23
|
+
|
|
24
|
+
static detect(cwd = process.cwd()) {
|
|
25
|
+
const markers = ['Gemfile', '.ruby-version', 'config.ru'];
|
|
26
|
+
return markers.some(m => fs.existsSync(path.join(cwd, m)));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = RubyAdapter;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rust LSP adapter — rust-analyzer.
|
|
6
|
+
*
|
|
7
|
+
* Installed through rustup as a toolchain component. System-mode only.
|
|
8
|
+
*/
|
|
9
|
+
class RustAdapter {
|
|
10
|
+
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
11
|
+
|
|
12
|
+
get name() { return 'rust'; }
|
|
13
|
+
get label() { return 'Rust (rust-analyzer)'; }
|
|
14
|
+
get binary() { return 'rust-analyzer'; }
|
|
15
|
+
get installMode() { return 'system'; }
|
|
16
|
+
|
|
17
|
+
installCommand() { return 'rustup component add rust-analyzer'; }
|
|
18
|
+
verifyCommand() { return 'rust-analyzer --version'; }
|
|
19
|
+
claudePluginId() { return null; }
|
|
20
|
+
|
|
21
|
+
static detect(cwd = process.cwd()) {
|
|
22
|
+
return fs.existsSync(path.join(cwd, 'Cargo.toml'));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = RustAdapter;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TypeScript / JavaScript LSP adapter.
|
|
6
|
+
*
|
|
7
|
+
* Language server: `typescript-language-server` (Node, npm).
|
|
8
|
+
* Install mode: project-local devDep — keeps the binary inside the consumer
|
|
9
|
+
* repo's node_modules and avoids polluting the user's global env.
|
|
10
|
+
*
|
|
11
|
+
* Detection signal: presence of `tsconfig.json`, `jsconfig.json`, or a
|
|
12
|
+
* `package.json` referencing typescript in deps/devDeps.
|
|
13
|
+
*/
|
|
14
|
+
class TypeScriptAdapter {
|
|
15
|
+
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
16
|
+
|
|
17
|
+
get name() { return 'typescript'; }
|
|
18
|
+
get label() { return 'TypeScript / JavaScript'; }
|
|
19
|
+
get binary() { return 'typescript-language-server'; }
|
|
20
|
+
|
|
21
|
+
/** 'npm-dev' = npm devDependency, 'system' = printed install instructions. */
|
|
22
|
+
get installMode() { return 'npm-dev'; }
|
|
23
|
+
|
|
24
|
+
/** npm package to install when mode='npm-dev'. */
|
|
25
|
+
get npmPackage() { return 'typescript-language-server typescript'; }
|
|
26
|
+
|
|
27
|
+
/** Shell command the user (or installer) should run. */
|
|
28
|
+
installCommand() {
|
|
29
|
+
return 'npm install --save-dev typescript-language-server typescript';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Verify the binary is reachable (no execution side effects). */
|
|
33
|
+
verifyCommand() { return 'npx --no-install typescript-language-server --version'; }
|
|
34
|
+
|
|
35
|
+
/** Claude Code code-intelligence plugin id, if any. Null = use generic LSP. */
|
|
36
|
+
claudePluginId() { return null; }
|
|
37
|
+
|
|
38
|
+
/** Autodetect whether this language is in scope for the project. */
|
|
39
|
+
static detect(cwd = process.cwd()) {
|
|
40
|
+
const markers = ['tsconfig.json', 'jsconfig.json'];
|
|
41
|
+
for (const m of markers) {
|
|
42
|
+
if (fs.existsSync(path.join(cwd, m))) return true;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
46
|
+
if (!fs.existsSync(pkgPath)) return false;
|
|
47
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
48
|
+
const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
49
|
+
return Boolean(allDeps.typescript);
|
|
50
|
+
} catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = TypeScriptAdapter;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const { detectAll, getAdapter, listAdapters } = require('./lsp-adapters');
|
|
3
|
+
const UI = require('./ui');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* High-level LSP installer used by `baldart configure` and the `lsp-bootstrap`
|
|
7
|
+
* skill / doctor step. Owns the install + verify side effects so the command
|
|
8
|
+
* layers stay thin.
|
|
9
|
+
*
|
|
10
|
+
* Contract:
|
|
11
|
+
* - Pure detection (no install) when only `detectLanguages` is called.
|
|
12
|
+
* - All mutating ops respect `--non-interactive` via the `interactive` flag.
|
|
13
|
+
* - Returns plain objects (no throws) so the caller can render results.
|
|
14
|
+
*/
|
|
15
|
+
class LspInstaller {
|
|
16
|
+
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
17
|
+
|
|
18
|
+
/** Languages whose marker files exist under cwd. */
|
|
19
|
+
detectLanguages() { return detectAll(this.cwd); }
|
|
20
|
+
|
|
21
|
+
/** Adapters that BALDART knows about (regardless of detection). */
|
|
22
|
+
knownAdapters() { return listAdapters(); }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a recommendation list from detected languages. Each entry carries
|
|
26
|
+
* the install command and the install mode so the UI can decide whether
|
|
27
|
+
* to run it automatically (npm-dev) or just print it (system).
|
|
28
|
+
*/
|
|
29
|
+
recommend(langs = this.detectLanguages()) {
|
|
30
|
+
return langs.map(name => {
|
|
31
|
+
const a = getAdapter(name, this.cwd);
|
|
32
|
+
return {
|
|
33
|
+
name: a.name,
|
|
34
|
+
label: a.label,
|
|
35
|
+
binary: a.binary,
|
|
36
|
+
installMode: a.installMode,
|
|
37
|
+
installCommand: a.installCommand(),
|
|
38
|
+
verifyCommand: a.verifyCommand(),
|
|
39
|
+
claudePluginId: a.claudePluginId()
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Install one or more language servers. Returns `{ installed, skipped, manual }`.
|
|
46
|
+
* - npm-dev adapters: executed in-process (npm install --save-dev …).
|
|
47
|
+
* - system adapters: never executed automatically; surfaced under `manual`
|
|
48
|
+
* so the caller can print the command for the user to run.
|
|
49
|
+
*
|
|
50
|
+
* `opts.interactive`: when true, asks confirmation per server (default true).
|
|
51
|
+
* `opts.dryRun`: when true, no commands are executed.
|
|
52
|
+
*/
|
|
53
|
+
async installServers(serverNames, opts = {}) {
|
|
54
|
+
const { interactive = true, dryRun = false } = opts;
|
|
55
|
+
const installed = [];
|
|
56
|
+
const skipped = [];
|
|
57
|
+
const manual = [];
|
|
58
|
+
|
|
59
|
+
for (const name of serverNames) {
|
|
60
|
+
let a;
|
|
61
|
+
try { a = getAdapter(name, this.cwd); }
|
|
62
|
+
catch (err) { skipped.push({ name, reason: err.message }); continue; }
|
|
63
|
+
|
|
64
|
+
if (a.installMode === 'system') {
|
|
65
|
+
manual.push({
|
|
66
|
+
name: a.name,
|
|
67
|
+
label: a.label,
|
|
68
|
+
command: a.installCommand()
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (interactive) {
|
|
74
|
+
const ok = await UI.confirm(`Install ${a.label} via \`${a.installCommand()}\`?`, true);
|
|
75
|
+
if (!ok) { skipped.push({ name, reason: 'user declined' }); continue; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (dryRun) { installed.push({ name, dryRun: true }); continue; }
|
|
79
|
+
|
|
80
|
+
const spinner = UI.spinner(`Installing ${a.label}…`).start();
|
|
81
|
+
try {
|
|
82
|
+
execSync(a.installCommand(), { cwd: this.cwd, stdio: 'pipe' });
|
|
83
|
+
spinner.succeed(`Installed ${a.label}`);
|
|
84
|
+
installed.push({ name });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
spinner.fail(`Failed to install ${a.label}`);
|
|
87
|
+
skipped.push({ name, reason: err.message.split('\n')[0] });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { installed, skipped, manual };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verify each named server's binary is reachable. Returns
|
|
96
|
+
* `[{ name, ok, output | error }]`. Never throws.
|
|
97
|
+
*/
|
|
98
|
+
verifyServers(serverNames) {
|
|
99
|
+
return serverNames.map(name => {
|
|
100
|
+
let a;
|
|
101
|
+
try { a = getAdapter(name, this.cwd); }
|
|
102
|
+
catch (err) { return { name, ok: false, error: err.message }; }
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const out = execSync(a.verifyCommand(), {
|
|
106
|
+
cwd: this.cwd,
|
|
107
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
108
|
+
timeout: 8000
|
|
109
|
+
}).toString().trim();
|
|
110
|
+
return { name, ok: true, output: out };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { name, ok: false, error: (err.message || '').split('\n')[0] };
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = LspInstaller;
|