baldart 3.12.0 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +24 -0
- package/VERSION +1 -1
- package/bin/baldart.js +14 -0
- package/framework/docs/LSP-LAYER.md +66 -9
- package/package.json +1 -1
- package/src/commands/add.js +24 -0
- package/src/commands/configure.js +44 -0
- package/src/commands/doctor.js +166 -8
- package/src/commands/push.js +0 -0
- package/src/commands/update.js +38 -1
- package/src/commands/version.js +5 -1
- package/src/utils/git.js +93 -0
- package/src/utils/lsp-adapters/go.js +1 -1
- package/src/utils/lsp-adapters/python.js +1 -1
- package/src/utils/lsp-adapters/ruby.js +1 -1
- package/src/utils/lsp-adapters/rust.js +1 -1
- package/src/utils/lsp-adapters/typescript.js +8 -2
- package/src/utils/lsp-installer.js +243 -0
- package/src/utils/state.js +27 -0
- package/src/utils/update-notifier.js +181 -0
package/src/commands/update.js
CHANGED
|
@@ -130,6 +130,29 @@ async function update(options = {}) {
|
|
|
130
130
|
const currentVersion = await git.getFrameworkVersion();
|
|
131
131
|
UI.success(`Current version: ${currentVersion}`);
|
|
132
132
|
|
|
133
|
+
// `.framework/` MUST be trackable — heal a stale `.gitignore` before
|
|
134
|
+
// any `git subtree pull` / `git add` runs. Idempotent. (since v3.14.0)
|
|
135
|
+
try {
|
|
136
|
+
const heal = await git.ensureFrameworkNotIgnored();
|
|
137
|
+
if (heal.wasFixed) {
|
|
138
|
+
UI.warning(`.framework/ was ignored by ${heal.source || 'a gitignore'} — auto-healed.`);
|
|
139
|
+
if (heal.removedLines > 0) {
|
|
140
|
+
UI.info(` Removed ${heal.removedLines} stale .framework line(s) from .gitignore.`);
|
|
141
|
+
}
|
|
142
|
+
if (heal.appendedNegation) {
|
|
143
|
+
UI.info(' Appended `!.framework` negation block to .gitignore (idempotent).');
|
|
144
|
+
}
|
|
145
|
+
if (heal.stillIgnored) {
|
|
146
|
+
UI.error('.framework/ is STILL ignored after auto-heal — likely a parent-dir .gitignore outside this repo.');
|
|
147
|
+
UI.info(`Source: ${heal.recheck.source}:${heal.recheck.line} (${heal.recheck.pattern})`);
|
|
148
|
+
UI.info('Resolve by removing that rule, then re-run `baldart update`.');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
UI.warning(`Could not check .gitignore for .framework: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
133
156
|
// Step 2: Check for updates
|
|
134
157
|
UI.header('STEP 2/5: Check for Updates');
|
|
135
158
|
|
|
@@ -246,7 +269,21 @@ async function update(options = {}) {
|
|
|
246
269
|
|
|
247
270
|
newVersion = await git.getFrameworkVersion();
|
|
248
271
|
UI.success(`New version: ${newVersion}`);
|
|
249
|
-
|
|
272
|
+
// Update the ledger and VERIFY the write landed. Prior to v3.13.0 this
|
|
273
|
+
// call used a silent catch — write failures (permissions, EBUSY) were
|
|
274
|
+
// hidden, and the user only noticed the drift weeks later via doctor.
|
|
275
|
+
// Now: visible warning + always-actionable next step.
|
|
276
|
+
try {
|
|
277
|
+
const after = State.recordUpdate({ from: currentVersion, to: newVersion });
|
|
278
|
+
if (after.installed_version !== newVersion) {
|
|
279
|
+
UI.warning(`Ledger write returned installed_version=${after.installed_version}, expected ${newVersion}.`);
|
|
280
|
+
UI.info('Run `baldart doctor` to reconcile the ledger.');
|
|
281
|
+
}
|
|
282
|
+
} catch (err) {
|
|
283
|
+
UI.warning(`Could not write .baldart/state.json: ${err.message}`);
|
|
284
|
+
UI.info(`Framework on disk is at v${newVersion} but the ledger still records v${currentVersion}.`);
|
|
285
|
+
UI.info('Run `baldart doctor` to reconcile, or check filesystem permissions on .baldart/state.json.');
|
|
286
|
+
}
|
|
250
287
|
|
|
251
288
|
// Re-apply the pre-update stash (if any). Conflicts mean we leave the
|
|
252
289
|
// stash intact and tell the user how to resolve — never silently drop.
|
package/src/commands/version.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const GitUtils = require('../utils/git');
|
|
2
2
|
const UI = require('../utils/ui');
|
|
3
3
|
const State = require('../utils/state');
|
|
4
|
+
const UpdateNotifier = require('../utils/update-notifier');
|
|
4
5
|
|
|
5
6
|
const REPO_DEFAULT = 'antbald/BALDART';
|
|
6
7
|
const FRAMEWORK_DIR = '.framework';
|
|
@@ -48,6 +49,7 @@ async function version(opts = {}) {
|
|
|
48
49
|
const frameworkVersion = await git.getFrameworkVersion();
|
|
49
50
|
const state = State.load();
|
|
50
51
|
const cliVersion = require('../../package.json').version;
|
|
52
|
+
const cliUpdate = UpdateNotifier.hint({ currentVersion: cliVersion });
|
|
51
53
|
|
|
52
54
|
// Optionally fetch upstream to compute drift (default: yes).
|
|
53
55
|
const offline = opts.offline === true;
|
|
@@ -75,7 +77,9 @@ async function version(opts = {}) {
|
|
|
75
77
|
`Last update: ${fmtDate(state.last_update_date)}`,
|
|
76
78
|
`Last push: v${state.last_pushed_version || '—'} ${state.last_push_date ? `(${fmtDate(state.last_push_date)})` : ''}`.trim(),
|
|
77
79
|
'',
|
|
78
|
-
|
|
80
|
+
cliUpdate
|
|
81
|
+
? `CLI: v${cliVersion} → v${cliUpdate.latest} available (npm i -g baldart@latest)`
|
|
82
|
+
: `CLI: v${cliVersion}`,
|
|
79
83
|
`Repository: https://github.com/${state.framework_repo || REPO_DEFAULT}`,
|
|
80
84
|
].filter((l) => l !== ''));
|
|
81
85
|
UI.newline();
|
package/src/utils/git.js
CHANGED
|
@@ -176,6 +176,99 @@ class GitUtils {
|
|
|
176
176
|
return '';
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
// ────────────────────────────────────────────────────────────────────
|
|
181
|
+
// `.framework/` is a git subtree — it MUST be tracked. If anything in
|
|
182
|
+
// the gitignore chain (project .gitignore, .git/info/exclude, global
|
|
183
|
+
// excludes) matches `.framework`, every subsequent `git add` on files
|
|
184
|
+
// under that directory fails with "paths are ignored by one of your
|
|
185
|
+
// .gitignore files". Detect + self-heal idempotently so install /
|
|
186
|
+
// update / push flows never trip over this. (since v3.14.0)
|
|
187
|
+
// ────────────────────────────────────────────────────────────────────
|
|
188
|
+
async checkFrameworkIgnored() {
|
|
189
|
+
try {
|
|
190
|
+
const out = await this.git.raw([
|
|
191
|
+
'check-ignore',
|
|
192
|
+
'-v',
|
|
193
|
+
'--no-index',
|
|
194
|
+
'--',
|
|
195
|
+
FRAMEWORK_DIR
|
|
196
|
+
]);
|
|
197
|
+
const line = (out || '').trim();
|
|
198
|
+
if (!line) return { ignored: false };
|
|
199
|
+
// Format: "<source>:<line>:<pattern>\t<path>"
|
|
200
|
+
const m = /^(.+?):(\d+):([^\t]+)\t/.exec(line);
|
|
201
|
+
return {
|
|
202
|
+
ignored: true,
|
|
203
|
+
source: m ? m[1] : 'unknown',
|
|
204
|
+
line: m ? Number(m[2]) : null,
|
|
205
|
+
pattern: m ? m[3] : null,
|
|
206
|
+
raw: line
|
|
207
|
+
};
|
|
208
|
+
} catch (_) {
|
|
209
|
+
// `git check-ignore` exits 1 when the path is not ignored — simple-git
|
|
210
|
+
// throws on non-zero. Treat as "not ignored".
|
|
211
|
+
return { ignored: false };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async ensureFrameworkNotIgnored() {
|
|
216
|
+
const status = await this.checkFrameworkIgnored();
|
|
217
|
+
if (!status.ignored) return { wasFixed: false };
|
|
218
|
+
|
|
219
|
+
const gitignorePath = path.join(this.cwd, '.gitignore');
|
|
220
|
+
const marker = '# BALDART: .framework/ is a git subtree — never ignore (auto-managed)';
|
|
221
|
+
const negationBlock = `\n${marker}\n!.framework\n!.framework/**\n`;
|
|
222
|
+
|
|
223
|
+
let original = '';
|
|
224
|
+
if (fs.existsSync(gitignorePath)) {
|
|
225
|
+
original = fs.readFileSync(gitignorePath, 'utf8');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 1. Strip standalone .framework rules from the project .gitignore so we
|
|
229
|
+
// don't leave stale "do ignore" lines that future maintainers might
|
|
230
|
+
// notice and try to "fix" by removing our negation.
|
|
231
|
+
const lines = original.split('\n');
|
|
232
|
+
const stripped = lines.filter((line) => {
|
|
233
|
+
const t = line.trim();
|
|
234
|
+
return !(
|
|
235
|
+
t === '.framework' ||
|
|
236
|
+
t === '.framework/' ||
|
|
237
|
+
t === '/.framework' ||
|
|
238
|
+
t === '/.framework/' ||
|
|
239
|
+
t === '.framework/*' ||
|
|
240
|
+
t === '/.framework/*'
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
const removedLines = lines.length - stripped.length;
|
|
244
|
+
|
|
245
|
+
// 2. Append the negation block (only once — idempotent across re-runs).
|
|
246
|
+
let next = stripped.join('\n');
|
|
247
|
+
if (!next.includes(marker)) {
|
|
248
|
+
if (next.length && !next.endsWith('\n')) next += '\n';
|
|
249
|
+
next += negationBlock;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (next !== original) {
|
|
253
|
+
fs.writeFileSync(gitignorePath, next, 'utf8');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 3. Verify the heal actually took effect. If the rule lives somewhere we
|
|
257
|
+
// cannot rewrite from here (a parent-dir .gitignore outside the repo,
|
|
258
|
+
// a non-standard core.excludesFile that overrides the project file),
|
|
259
|
+
// surface the leftover state to the caller instead of pretending.
|
|
260
|
+
const recheck = await this.checkFrameworkIgnored();
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
wasFixed: true,
|
|
264
|
+
stillIgnored: recheck.ignored,
|
|
265
|
+
removedLines,
|
|
266
|
+
appendedNegation: !original.includes(marker),
|
|
267
|
+
source: status.source,
|
|
268
|
+
pattern: status.pattern,
|
|
269
|
+
recheck
|
|
270
|
+
};
|
|
271
|
+
}
|
|
179
272
|
}
|
|
180
273
|
|
|
181
274
|
module.exports = GitUtils;
|
|
@@ -19,7 +19,7 @@ class GoAdapter {
|
|
|
19
19
|
|
|
20
20
|
installCommand() { return 'go install golang.org/x/tools/gopls@latest'; }
|
|
21
21
|
verifyCommand() { return 'gopls version'; }
|
|
22
|
-
claudePluginId() { return
|
|
22
|
+
claudePluginId() { return 'gopls-lsp@claude-plugins-official'; }
|
|
23
23
|
|
|
24
24
|
static detect(cwd = process.cwd()) {
|
|
25
25
|
return fs.existsSync(path.join(cwd, 'go.mod'));
|
|
@@ -23,7 +23,7 @@ class PythonAdapter {
|
|
|
23
23
|
|
|
24
24
|
installCommand() { return 'npm install --save-dev pyright'; }
|
|
25
25
|
verifyCommand() { return 'npx --no-install pyright --version'; }
|
|
26
|
-
claudePluginId() { return
|
|
26
|
+
claudePluginId() { return 'pyright-lsp@claude-plugins-official'; }
|
|
27
27
|
|
|
28
28
|
static detect(cwd = process.cwd()) {
|
|
29
29
|
const markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile'];
|
|
@@ -19,7 +19,7 @@ class RubyAdapter {
|
|
|
19
19
|
|
|
20
20
|
installCommand() { return 'gem install ruby-lsp'; }
|
|
21
21
|
verifyCommand() { return 'ruby-lsp --version'; }
|
|
22
|
-
claudePluginId() { return
|
|
22
|
+
claudePluginId() { return 'ruby-lsp@claude-plugins-official'; }
|
|
23
23
|
|
|
24
24
|
static detect(cwd = process.cwd()) {
|
|
25
25
|
const markers = ['Gemfile', '.ruby-version', 'config.ru'];
|
|
@@ -16,7 +16,7 @@ class RustAdapter {
|
|
|
16
16
|
|
|
17
17
|
installCommand() { return 'rustup component add rust-analyzer'; }
|
|
18
18
|
verifyCommand() { return 'rust-analyzer --version'; }
|
|
19
|
-
claudePluginId() { return
|
|
19
|
+
claudePluginId() { return 'rust-analyzer-lsp@claude-plugins-official'; }
|
|
20
20
|
|
|
21
21
|
static detect(cwd = process.cwd()) {
|
|
22
22
|
return fs.existsSync(path.join(cwd, 'Cargo.toml'));
|
|
@@ -32,8 +32,14 @@ class TypeScriptAdapter {
|
|
|
32
32
|
/** Verify the binary is reachable (no execution side effects). */
|
|
33
33
|
verifyCommand() { return 'npx --no-install typescript-language-server --version'; }
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Claude Code code-intelligence plugin id, if any. Null = no companion plugin.
|
|
37
|
+
* When non-null, `LspInstaller.installClaudePlugins()` installs this plugin
|
|
38
|
+
* from the `claude-plugins-official` marketplace (anthropics/claude-plugins-official)
|
|
39
|
+
* so the LSP layer is wired into Claude Code natively instead of leaving the
|
|
40
|
+
* user to enable it manually after `baldart configure`.
|
|
41
|
+
*/
|
|
42
|
+
claudePluginId() { return 'typescript-lsp@claude-plugins-official'; }
|
|
37
43
|
|
|
38
44
|
/** Autodetect whether this language is in scope for the project. */
|
|
39
45
|
static detect(cwd = process.cwd()) {
|
|
@@ -1,7 +1,42 @@
|
|
|
1
1
|
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
2
5
|
const { detectAll, getAdapter, listAdapters } = require('./lsp-adapters');
|
|
3
6
|
const UI = require('./ui');
|
|
4
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The `claude` CLI is built on Ink (React for TUIs) and writes stdout in a
|
|
10
|
+
* way that truncates at ~8 KB when piped directly to a node child process —
|
|
11
|
+
* regardless of `maxBuffer` setting. Workaround: shell-redirect to a temp
|
|
12
|
+
* file and read the file back. Used only for read-only `--json` queries.
|
|
13
|
+
*
|
|
14
|
+
* Returns the file contents as a string, or throws (caller catches).
|
|
15
|
+
*/
|
|
16
|
+
function captureClaudeJson(args, { timeout = 10000 } = {}) {
|
|
17
|
+
const tmp = path.join(os.tmpdir(), `baldart-claude-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
18
|
+
try {
|
|
19
|
+
execSync(`claude ${args} > ${JSON.stringify(tmp)} 2>/dev/null`, {
|
|
20
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
21
|
+
timeout,
|
|
22
|
+
shell: '/bin/bash',
|
|
23
|
+
});
|
|
24
|
+
return fs.readFileSync(tmp, 'utf8');
|
|
25
|
+
} finally {
|
|
26
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* best-effort cleanup */ }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Anthropic's official marketplace for native Claude Code LSP plugins
|
|
32
|
+
* (typescript-lsp, pyright-lsp, gopls-lsp, rust-analyzer-lsp, ruby-lsp, …).
|
|
33
|
+
* Every adapter's `claudePluginId()` returns an id namespaced under this
|
|
34
|
+
* marketplace, so the installer hard-codes it here. If a future adapter needs
|
|
35
|
+
* a different marketplace, lift this into a per-adapter field.
|
|
36
|
+
*/
|
|
37
|
+
const OFFICIAL_MARKETPLACE_NAME = 'claude-plugins-official';
|
|
38
|
+
const OFFICIAL_MARKETPLACE_SOURCE = 'anthropics/claude-plugins-official';
|
|
39
|
+
|
|
5
40
|
/**
|
|
6
41
|
* High-level LSP installer used by `baldart configure` and the `lsp-bootstrap`
|
|
7
42
|
* skill / doctor step. Owns the install + verify side effects so the command
|
|
@@ -11,6 +46,10 @@ const UI = require('./ui');
|
|
|
11
46
|
* - Pure detection (no install) when only `detectLanguages` is called.
|
|
12
47
|
* - All mutating ops respect `--non-interactive` via the `interactive` flag.
|
|
13
48
|
* - Returns plain objects (no throws) so the caller can render results.
|
|
49
|
+
* - Claude plugin install ops gate on the `claude` CLI being on PATH AND on
|
|
50
|
+
* `claude` being present in `tools.enabled`; when either fails the methods
|
|
51
|
+
* report `{ skipped: [...], reason }` so the caller can render a hint
|
|
52
|
+
* instead of erroring.
|
|
14
53
|
*/
|
|
15
54
|
class LspInstaller {
|
|
16
55
|
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
@@ -91,6 +130,210 @@ class LspInstaller {
|
|
|
91
130
|
return { installed, skipped, manual };
|
|
92
131
|
}
|
|
93
132
|
|
|
133
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
134
|
+
// Native Claude Code companion plugins (since v3.14.0)
|
|
135
|
+
//
|
|
136
|
+
// For each language whose adapter declares a `claudePluginId()`, this layer
|
|
137
|
+
// ensures the matching plugin from the `claude-plugins-official` marketplace
|
|
138
|
+
// is installed. Without it, enabling `features.has_lsp_layer: true` only
|
|
139
|
+
// installs the language-server binary — Claude Code itself never sees it,
|
|
140
|
+
// and agents fall back to Grep silently. Wiring the two together is the
|
|
141
|
+
// whole point of opting into LSP, so the installer treats this as part of
|
|
142
|
+
// the install, not an optional extra.
|
|
143
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** True iff the `claude` CLI is on PATH and answers `--version`. */
|
|
146
|
+
isClaudeCliAvailable() {
|
|
147
|
+
try {
|
|
148
|
+
execSync('claude --version', { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
|
|
149
|
+
return true;
|
|
150
|
+
} catch { return false; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Idempotently register the official marketplace. Reads the existing list
|
|
155
|
+
* first so we never spam `claude plugin marketplace add` on every configure
|
|
156
|
+
* run. Returns `{ ok, alreadyPresent, error? }`. Never throws.
|
|
157
|
+
*/
|
|
158
|
+
ensureClaudeMarketplace() {
|
|
159
|
+
if (!this.isClaudeCliAvailable()) {
|
|
160
|
+
return { ok: false, error: 'claude CLI not on PATH' };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const out = captureClaudeJson('plugin marketplace list --json', { timeout: 8000 });
|
|
164
|
+
const parsed = JSON.parse(out);
|
|
165
|
+
// Tolerate both shapes: object map keyed by name, or array of { name, … }.
|
|
166
|
+
const names = Array.isArray(parsed)
|
|
167
|
+
? parsed.map(m => m && m.name).filter(Boolean)
|
|
168
|
+
: Object.keys(parsed || {});
|
|
169
|
+
if (names.includes(OFFICIAL_MARKETPLACE_NAME)) {
|
|
170
|
+
return { ok: true, alreadyPresent: true };
|
|
171
|
+
}
|
|
172
|
+
} catch (_) {
|
|
173
|
+
// Fall through to the add — if the add itself fails, we report that.
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
execSync(`claude plugin marketplace add ${OFFICIAL_MARKETPLACE_SOURCE} --scope user`, {
|
|
177
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
178
|
+
timeout: 60000 // first add clones the marketplace repo; allow time
|
|
179
|
+
});
|
|
180
|
+
return { ok: true, alreadyPresent: false };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return { ok: false, error: (err.message || '').split('\n')[0] };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Return the set of plugin ids (e.g. `typescript-lsp@claude-plugins-official`)
|
|
188
|
+
* currently installed at ANY scope. Used to skip already-installed plugins.
|
|
189
|
+
* Returns `Set<string>`; empty set on any failure (degrade open — we'd rather
|
|
190
|
+
* issue a redundant install than block on a parser hiccup).
|
|
191
|
+
*/
|
|
192
|
+
listInstalledClaudePlugins() {
|
|
193
|
+
const ids = new Set();
|
|
194
|
+
try {
|
|
195
|
+
const out = captureClaudeJson('plugin list --json', { timeout: 8000 });
|
|
196
|
+
const parsed = JSON.parse(out);
|
|
197
|
+
const entries = Array.isArray(parsed) ? parsed : Object.values(parsed || {});
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
// Be tolerant of shape drift: pull the id from `name`, `id`, or
|
|
200
|
+
// the object's own key.
|
|
201
|
+
const id = (entry && (entry.id || entry.name)) || null;
|
|
202
|
+
if (id) ids.add(id);
|
|
203
|
+
}
|
|
204
|
+
// Also handle the `{ "<id>@<marketplace>": [...] }` map shape that the
|
|
205
|
+
// on-disk `installed_plugins.json` uses, in case `list --json` mirrors it.
|
|
206
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
207
|
+
for (const key of Object.keys(parsed)) {
|
|
208
|
+
if (key.includes('@')) ids.add(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (_) { /* swallow — empty set means "install, don't skip" */ }
|
|
212
|
+
return ids;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Install the native Claude Code companion plugins for the given language
|
|
217
|
+
* server names. Idempotent: skips plugins already installed, skips adapters
|
|
218
|
+
* whose `claudePluginId()` is null.
|
|
219
|
+
*
|
|
220
|
+
* Gating:
|
|
221
|
+
* - `opts.toolsEnabled` (array, default `['claude']`): when `claude` is
|
|
222
|
+
* not in the array, the whole method is a silent no-op (Codex-only users
|
|
223
|
+
* don't want Claude marketplace mutation).
|
|
224
|
+
* - When the `claude` CLI is missing, returns `{ skipped, reason }` so the
|
|
225
|
+
* caller can render a manual-install hint.
|
|
226
|
+
*
|
|
227
|
+
* Returns `{ installed: [...], skipped: [...], manual: [...], marketplace }`.
|
|
228
|
+
* Never throws.
|
|
229
|
+
*/
|
|
230
|
+
async installClaudePlugins(serverNames, opts = {}) {
|
|
231
|
+
const {
|
|
232
|
+
toolsEnabled = ['claude'],
|
|
233
|
+
scope = 'user', // LSP plugins are machine-wide useful — avoid N project copies
|
|
234
|
+
interactive = false
|
|
235
|
+
} = opts;
|
|
236
|
+
|
|
237
|
+
if (!Array.isArray(toolsEnabled) || !toolsEnabled.includes('claude')) {
|
|
238
|
+
return { skipped: [], installed: [], manual: [], reason: 'claude not in tools.enabled' };
|
|
239
|
+
}
|
|
240
|
+
if (!this.isClaudeCliAvailable()) {
|
|
241
|
+
return {
|
|
242
|
+
skipped: serverNames.map(n => ({ name: n, reason: 'claude CLI not on PATH' })),
|
|
243
|
+
installed: [], manual: [],
|
|
244
|
+
reason: 'claude CLI not on PATH'
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Resolve <serverName> → <claudePluginId>, dropping adapters without one.
|
|
249
|
+
const targets = [];
|
|
250
|
+
for (const name of serverNames) {
|
|
251
|
+
try {
|
|
252
|
+
const a = getAdapter(name, this.cwd);
|
|
253
|
+
const pid = a.claudePluginId();
|
|
254
|
+
if (pid) targets.push({ name, label: a.label, pluginId: pid });
|
|
255
|
+
} catch { /* unknown adapter — ignore */ }
|
|
256
|
+
}
|
|
257
|
+
if (!targets.length) {
|
|
258
|
+
return { installed: [], skipped: [], manual: [], reason: 'no adapters declare a Claude plugin id' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Idempotency 1: marketplace add (no-op if already registered).
|
|
262
|
+
const mkt = this.ensureClaudeMarketplace();
|
|
263
|
+
if (!mkt.ok) {
|
|
264
|
+
return {
|
|
265
|
+
installed: [],
|
|
266
|
+
skipped: targets.map(t => ({ name: t.name, reason: `marketplace not registered (${mkt.error || 'unknown'})` })),
|
|
267
|
+
manual: targets.map(t => ({
|
|
268
|
+
name: t.name,
|
|
269
|
+
label: t.label,
|
|
270
|
+
command: `claude plugin marketplace add ${OFFICIAL_MARKETPLACE_SOURCE} && claude plugin install ${t.pluginId} --scope ${scope}`
|
|
271
|
+
})),
|
|
272
|
+
marketplace: mkt
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Idempotency 2: skip plugins already installed at any scope.
|
|
277
|
+
const already = this.listInstalledClaudePlugins();
|
|
278
|
+
|
|
279
|
+
const installed = [];
|
|
280
|
+
const skipped = [];
|
|
281
|
+
const manual = [];
|
|
282
|
+
|
|
283
|
+
for (const t of targets) {
|
|
284
|
+
if (already.has(t.pluginId)) {
|
|
285
|
+
skipped.push({ name: t.name, reason: 'plugin already installed' });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (interactive) {
|
|
290
|
+
const ok = await UI.confirm(
|
|
291
|
+
`Install native Claude Code plugin \`${t.pluginId}\` (--scope ${scope})?`,
|
|
292
|
+
true
|
|
293
|
+
);
|
|
294
|
+
if (!ok) { skipped.push({ name: t.name, reason: 'user declined' }); continue; }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const spinner = UI.spinner(`Installing ${t.pluginId}…`).start();
|
|
298
|
+
try {
|
|
299
|
+
execSync(`claude plugin install ${t.pluginId} --scope ${scope}`, {
|
|
300
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
301
|
+
timeout: 60000
|
|
302
|
+
});
|
|
303
|
+
spinner.succeed(`Installed ${t.pluginId}`);
|
|
304
|
+
installed.push({ name: t.name, pluginId: t.pluginId });
|
|
305
|
+
} catch (err) {
|
|
306
|
+
spinner.fail(`Failed to install ${t.pluginId}`);
|
|
307
|
+
manual.push({
|
|
308
|
+
name: t.name,
|
|
309
|
+
label: t.label,
|
|
310
|
+
command: `claude plugin install ${t.pluginId} --scope ${scope}`
|
|
311
|
+
});
|
|
312
|
+
skipped.push({ name: t.name, reason: (err.message || '').split('\n')[0] });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { installed, skipped, manual, marketplace: mkt };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* For each declared server, report whether the matching Claude plugin is
|
|
321
|
+
* installed. Returns `[{ name, pluginId, installed }]`. Adapters without a
|
|
322
|
+
* `claudePluginId()` are reported as `{ pluginId: null, installed: null }`
|
|
323
|
+
* so callers can filter them out.
|
|
324
|
+
*/
|
|
325
|
+
verifyClaudePlugins(serverNames) {
|
|
326
|
+
const already = this.listInstalledClaudePlugins();
|
|
327
|
+
return serverNames.map(name => {
|
|
328
|
+
let a;
|
|
329
|
+
try { a = getAdapter(name, this.cwd); }
|
|
330
|
+
catch { return { name, pluginId: null, installed: null }; }
|
|
331
|
+
const pid = a.claudePluginId();
|
|
332
|
+
if (!pid) return { name, pluginId: null, installed: null };
|
|
333
|
+
return { name, pluginId: pid, installed: already.has(pid) };
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
94
337
|
/**
|
|
95
338
|
* Verify each named server's binary is reachable. Returns
|
|
96
339
|
* `[{ name, ok, output | error }]`. Never throws.
|
package/src/utils/state.js
CHANGED
|
@@ -106,6 +106,32 @@ function recordUpdate({ from, to }, cwd = process.cwd()) {
|
|
|
106
106
|
return state;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Reconcile the ledger's installed_version with the actual framework version
|
|
111
|
+
* on disk (read from .framework/VERSION). Used by `baldart doctor` when it
|
|
112
|
+
* detects a mismatch between the payload and the ledger — typically caused by
|
|
113
|
+
* an interrupted update flow, a manual commit that excluded state.json, or a
|
|
114
|
+
* filesystem write failure that the silent catch in update.js used to hide.
|
|
115
|
+
*
|
|
116
|
+
* Idempotent: calling with the already-current version is a no-op (no history
|
|
117
|
+
* entry produced). History entry uses event "ledger-reconcile" so the source
|
|
118
|
+
* of the bump is auditable separately from real updates.
|
|
119
|
+
*/
|
|
120
|
+
function reconcileInstalledVersion({ to, reason }, cwd = process.cwd()) {
|
|
121
|
+
const state = load(cwd);
|
|
122
|
+
const from = state.installed_version;
|
|
123
|
+
if (from === to) return { state, changed: false };
|
|
124
|
+
state.installed_version = to;
|
|
125
|
+
state.history = appendHistory(state, {
|
|
126
|
+
event: 'ledger-reconcile',
|
|
127
|
+
from,
|
|
128
|
+
to,
|
|
129
|
+
note: reason || 'reconcile ledger with .framework/VERSION'
|
|
130
|
+
});
|
|
131
|
+
save(state, cwd);
|
|
132
|
+
return { state, changed: true, from, to };
|
|
133
|
+
}
|
|
134
|
+
|
|
109
135
|
function recordPush({ from, to, description }, cwd = process.cwd()) {
|
|
110
136
|
const state = load(cwd);
|
|
111
137
|
state.last_pushed_version = to;
|
|
@@ -137,6 +163,7 @@ module.exports = {
|
|
|
137
163
|
recordInstall,
|
|
138
164
|
recordUpdate,
|
|
139
165
|
recordPush,
|
|
166
|
+
reconcileInstalledVersion,
|
|
140
167
|
readFrameworkVersion,
|
|
141
168
|
STATE_FILE,
|
|
142
169
|
STATE_VERSION,
|