claudeup 6.5.0 → 6.6.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/package.json +4 -4
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/catalog-notice.test.ts +3 -3
- package/src/__tests__/cli-apply-seams.test.ts +2 -0
- package/src/__tests__/cli-tool-commands.test.ts +1 -1
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +142 -2
- package/src/__tests__/content-drift.test.ts +60 -0
- package/src/__tests__/enabled-not-installed.test.ts +1 -2
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
- package/src/__tests__/marketplace-badge.test.ts +1 -1
- package/src/__tests__/marketplaces.test.ts +1 -0
- package/src/__tests__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/scope-action.test.ts +69 -19
- package/src/__tests__/shell-script-callers.test.ts +8 -8
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/update-apply.test.ts +283 -1
- package/src/__tests__/update-plan.test.ts +773 -31
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/cli/router.ts +0 -5
- package/src/cli/update-view.ts +92 -12
- package/src/cli/update.ts +114 -38
- package/src/data/marketplaces.ts +13 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +1 -173
- package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
- package/src/services/gitignore-fixer.ts +1 -1
- package/src/services/marketplace-refresh.ts +30 -0
- package/src/services/plugin-manager.ts +200 -283
- package/src/services/resolver.ts +7 -2
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/update-engine.ts +220 -32
- package/src/services/update-plan.ts +640 -160
- package/src/services/version-snapshot.ts +1 -1
- package/src/types/bun.d.ts +6 -0
- package/src/ui/App.tsx +2 -27
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/pluginsAdapter.ts +58 -1
- package/src/ui/components/modals/ModalContainer.tsx +0 -26
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +2 -3
- package/src/ui/renderers/cliToolRenderers.tsx +108 -67
- package/src/ui/renderers/pluginRenderers.tsx +157 -98
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/screens/CliToolsScreen.tsx +154 -280
- package/src/ui/screens/PluginsScreen.tsx +156 -140
- package/src/ui/state/types.ts +0 -7
- package/src/__tests__/dual-write-prevention.test.ts +0 -380
- package/src/__tests__/gap-fill-versions.test.ts +0 -389
- package/src/__tests__/plugin-version-check.test.ts +0 -762
- package/src/cli/claude.ts +0 -21
- package/src/prerunner/index.ts +0 -500
- package/src/services/plugin-version-check.ts +0 -360
- package/src/services/update-cache.ts +0 -78
- package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
- package/src/ui/hooks/useMismatchModal.ts +0 -89
package/src/cli/update.ts
CHANGED
|
@@ -34,11 +34,14 @@
|
|
|
34
34
|
* `cli/update-view.ts` owns every string. Nothing in this file formats.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
import { selectProfileId } from "../services/active-closure.js";
|
|
37
38
|
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
38
39
|
import { clearContentDriftCache } from "../services/content-drift.js";
|
|
39
|
-
import { checkBinaries } from "../services/doctor-bins.js";
|
|
40
40
|
import { validateManifest } from "../services/manifest.js";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
refreshRegisteredMarketplaces,
|
|
43
|
+
reportRegisteredMarketplaces,
|
|
44
|
+
} from "../services/marketplace-refresh.js";
|
|
42
45
|
import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
|
|
43
46
|
import {
|
|
44
47
|
type PluginInfo,
|
|
@@ -48,6 +51,7 @@ import {
|
|
|
48
51
|
import { resolveProfile } from "../services/resolver.js";
|
|
49
52
|
import { getInstalledSkillNames } from "../services/skills-manager.js";
|
|
50
53
|
import { activeProfile } from "../services/symlink-manager.js";
|
|
54
|
+
import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
|
|
51
55
|
// The apply half lives in services/ so the TUI runs the SAME code — see the
|
|
52
56
|
// header of update-engine.ts for what drifted while it did not.
|
|
53
57
|
import {
|
|
@@ -77,11 +81,15 @@ import {
|
|
|
77
81
|
header,
|
|
78
82
|
info,
|
|
79
83
|
note as noteLine,
|
|
84
|
+
nothingToApplyNotice,
|
|
80
85
|
ok as okLine,
|
|
81
86
|
planReport,
|
|
82
87
|
stepFrame,
|
|
83
88
|
stepRecord,
|
|
84
89
|
summaryLine,
|
|
90
|
+
uncheckedGateNotice,
|
|
91
|
+
unknownPluginsNotice,
|
|
92
|
+
unknownToolsNotice,
|
|
85
93
|
warn as warnLine,
|
|
86
94
|
} from "./update-view.js";
|
|
87
95
|
|
|
@@ -195,10 +203,13 @@ export async function runUpdateCommand(
|
|
|
195
203
|
|
|
196
204
|
// Active profile, not the union — "update my dependencies" means the set in
|
|
197
205
|
// use. A single-profile manifest needs no activation to be unambiguous.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
206
|
+
// Chosen through the same function the TUI uses, so both judge a plugin
|
|
207
|
+
// against the same pin.
|
|
208
|
+
const targetId = selectProfileId(
|
|
209
|
+
profileIds,
|
|
210
|
+
flags.profile,
|
|
211
|
+
await activeProfile(projectPath),
|
|
212
|
+
);
|
|
202
213
|
if (!targetId) {
|
|
203
214
|
console.error(
|
|
204
215
|
failLine(
|
|
@@ -246,7 +257,22 @@ export async function runUpdateCommand(
|
|
|
246
257
|
// clones first, then drop every cached answer derived from the old HEAD —
|
|
247
258
|
// skipping this is how a clone sits days behind while every plugin reads as
|
|
248
259
|
// up to date.
|
|
260
|
+
// ...but NOT under --dry-run or --check. Both promise to write nothing, and
|
|
261
|
+
// `git pull --ff-only` on a clone under ~/.claude/plugins/marketplaces/ is a
|
|
262
|
+
// write — it advances the user's marketplace to a new commit. The help text
|
|
263
|
+
// said "Writes nothing, exits 0" while the run printed "1 refreshed" in its
|
|
264
|
+
// own output, which is where this was caught. A read-only run plans against
|
|
265
|
+
// the clone as it stands; the notice below says so, so an unrefreshed
|
|
266
|
+
// catalog is never mistaken for a current one.
|
|
249
267
|
const refresh = await runStep(steps.marketplaces, async (say) => {
|
|
268
|
+
if (flags.dryRun || flags.check) {
|
|
269
|
+
say("read-only — selecting, not pulling");
|
|
270
|
+
// Report-only, NOT a skip: the autoUpdate-disabled verdict is a pure
|
|
271
|
+
// read and is the one diagnostic a read-only run must not lose.
|
|
272
|
+
const report = await reportRegisteredMarketplaces();
|
|
273
|
+
steps.marketplaces.detail = `not refreshed (read-only) · ${report.skipped.length} eligible`;
|
|
274
|
+
return report;
|
|
275
|
+
}
|
|
250
276
|
let settled = 0;
|
|
251
277
|
return refreshRegisteredMarketplaces([], {
|
|
252
278
|
onStart: (names) => {
|
|
@@ -311,26 +337,39 @@ export async function runUpdateCommand(
|
|
|
311
337
|
return map;
|
|
312
338
|
});
|
|
313
339
|
|
|
314
|
-
|
|
340
|
+
// The tool probe: installed version, owner, and the owning installer's own
|
|
341
|
+
// "behind?" answer — the same probe the TUI's CLI Tools screen uses. It used
|
|
342
|
+
// to be a presence check, which is why every present unpinned tool planned
|
|
343
|
+
// as an upgrade on every run.
|
|
344
|
+
const toolFacts = await runStep(steps.binaries, async (say) => {
|
|
315
345
|
if (closure.bins.length === 0) {
|
|
316
346
|
steps.binaries.detail = "none declared";
|
|
317
347
|
return [];
|
|
318
348
|
}
|
|
319
349
|
steps.binaries.progress = { done: 0, total: closure.bins.length };
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
(
|
|
324
|
-
steps.binaries.progress = { done:
|
|
325
|
-
say(name);
|
|
350
|
+
const facts = await probeTools(closure.bins, REAL_TOOL_PROBE_IO, {
|
|
351
|
+
// `settled`, not `index`: results arrive in completion order, and a
|
|
352
|
+
// meter driven by index would jump backwards.
|
|
353
|
+
onResult: (f, { settled, total }) => {
|
|
354
|
+
steps.binaries.progress = { done: settled, total };
|
|
355
|
+
say(f.name);
|
|
326
356
|
},
|
|
327
|
-
);
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
357
|
+
});
|
|
358
|
+
const items = planBinUpdates(closure.bins, facts);
|
|
359
|
+
const n = (a: string) => items.filter((i) => i.action === a).length;
|
|
360
|
+
const behind = n("upgrade");
|
|
361
|
+
const unknown = n("unknown");
|
|
362
|
+
const missing = n("install");
|
|
363
|
+
steps.binaries.detail = [
|
|
364
|
+
`${items.length} checked`,
|
|
365
|
+
behind > 0 ? `${behind} behind` : "",
|
|
366
|
+
unknown > 0 ? `${unknown} unknown` : "",
|
|
367
|
+
missing > 0 ? `${missing} missing` : "",
|
|
368
|
+
]
|
|
369
|
+
.filter(Boolean)
|
|
370
|
+
.join(" · ");
|
|
371
|
+
if (missing + unknown > 0) steps.binaries.state = "warn";
|
|
372
|
+
return facts;
|
|
334
373
|
});
|
|
335
374
|
|
|
336
375
|
const installedSkills = await runStep(steps.skills, async (say) => {
|
|
@@ -353,7 +392,7 @@ export async function runUpdateCommand(
|
|
|
353
392
|
const plan: UpdatePlan = {
|
|
354
393
|
profileId: targetId,
|
|
355
394
|
plugins: planPluginUpdates(closure.plugins, catalog),
|
|
356
|
-
bins: planBinUpdates(closure.bins,
|
|
395
|
+
bins: planBinUpdates(closure.bins, toolFacts),
|
|
357
396
|
skills: planSkillUpdates(closure.skills, installedSkills),
|
|
358
397
|
binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
|
|
359
398
|
};
|
|
@@ -361,23 +400,34 @@ export async function runUpdateCommand(
|
|
|
361
400
|
for (const line of planReport(plan)) console.log(line);
|
|
362
401
|
|
|
363
402
|
const counts = summarizePlan(plan);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
403
|
+
const unknownToolNames = plan.bins
|
|
404
|
+
.filter((b) => b.action === "unknown")
|
|
405
|
+
.map((b) => b.name);
|
|
406
|
+
const toolUnknownLine = () =>
|
|
407
|
+
unknownToolsNotice(counts.unknownTools, unknownToolNames);
|
|
408
|
+
if (counts.unknownPlugins > 0 || counts.unknownTools > 0) console.log("");
|
|
409
|
+
if (counts.unknownPlugins > 0) {
|
|
410
|
+
console.log(unknownPluginsNotice(counts.unknownPlugins));
|
|
371
411
|
}
|
|
412
|
+
if (counts.unknownTools > 0) console.log(toolUnknownLine());
|
|
372
413
|
|
|
373
414
|
// `--check` gates on planIsBehind, not planHasWork. An already-installed
|
|
374
415
|
// skill is re-fetched on every apply because its manifest ref carries no
|
|
375
|
-
// content hash,
|
|
376
|
-
//
|
|
377
|
-
//
|
|
416
|
+
// content hash, which is no evidence that anything is out of date — counting
|
|
417
|
+
// it made the gate exit 1 forever for any profile declaring a single skill.
|
|
418
|
+
//
|
|
419
|
+
// An `unknown` does not fail the gate, for plugins and tools alike (see
|
|
420
|
+
// planIsBehind). The same argument applies to both: a plugin whose
|
|
421
|
+
// marketplace publishes no version, or a tool another npm owns, is unknown
|
|
422
|
+
// permanently, so failing on it is the skill case again — this profile's 5
|
|
423
|
+
// unversioned official plugins made `--check` exit 1 on every run. A tool
|
|
424
|
+
// or plugin something says is BEHIND still does fail it. Because a failed
|
|
425
|
+
// check now passes silently by exit code, the unchecked count is printed
|
|
426
|
+
// beside the verdict; the per-kind notices and each row's reason are above.
|
|
378
427
|
if (flags.check) {
|
|
379
428
|
console.log("");
|
|
380
429
|
if (!planIsBehind(plan)) {
|
|
430
|
+
if (counts.unknown > 0) console.log(uncheckedGateNotice(counts.unknown));
|
|
381
431
|
console.log(okLine("Nothing missing and nothing behind."));
|
|
382
432
|
return 0;
|
|
383
433
|
}
|
|
@@ -406,12 +456,11 @@ export async function runUpdateCommand(
|
|
|
406
456
|
// fine". But nothing is APPLIED for an unknown — the apply loop skips it —
|
|
407
457
|
// so reporting on `planHasWork` alone claimed a run had happened when it
|
|
408
458
|
// had not. Say what is actually true instead.
|
|
409
|
-
if (counts.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
);
|
|
459
|
+
if (counts.unknownPlugins + counts.unknownTools > 0) {
|
|
460
|
+
if (counts.unknownPlugins > 0) {
|
|
461
|
+
console.log(nothingToApplyNotice(counts.unknownPlugins));
|
|
462
|
+
}
|
|
463
|
+
if (counts.unknownTools > 0) console.log(toolUnknownLine());
|
|
415
464
|
return 0;
|
|
416
465
|
}
|
|
417
466
|
console.log(okLine("Everything is up to date."));
|
|
@@ -466,6 +515,8 @@ export async function runUpdateCommand(
|
|
|
466
515
|
};
|
|
467
516
|
|
|
468
517
|
const applyLive = new LiveRegion();
|
|
518
|
+
/** The last detail each item finished with — the reason, for an unchanged one. */
|
|
519
|
+
const finishedDetail = new Map<string, string>();
|
|
469
520
|
const reporter: ApplyReporter = {
|
|
470
521
|
begin: (name) => {
|
|
471
522
|
state.current = name;
|
|
@@ -473,12 +524,17 @@ export async function runUpdateCommand(
|
|
|
473
524
|
},
|
|
474
525
|
finish: (succeeded, name, detail, ms) => {
|
|
475
526
|
state.done++;
|
|
527
|
+
finishedDetail.set(name, detail);
|
|
476
528
|
applyLive.note(
|
|
477
529
|
applyRow(
|
|
478
530
|
succeeded,
|
|
479
531
|
name,
|
|
480
532
|
nameWidth,
|
|
481
|
-
succeeded
|
|
533
|
+
!succeeded
|
|
534
|
+
? fg(brand.danger, detail)
|
|
535
|
+
: detail.startsWith("no change — ")
|
|
536
|
+
? fg(brand.warning, detail)
|
|
537
|
+
: dim(detail),
|
|
482
538
|
ms,
|
|
483
539
|
),
|
|
484
540
|
);
|
|
@@ -501,9 +557,29 @@ export async function runUpdateCommand(
|
|
|
501
557
|
counts.unknown,
|
|
502
558
|
failed.length,
|
|
503
559
|
Date.now() - startedAt,
|
|
560
|
+
outcome.unchanged.length,
|
|
504
561
|
))
|
|
505
562
|
console.log(line);
|
|
506
563
|
|
|
564
|
+
// Exited 0, read back unmoved. Reported, never fatal — the same precedent
|
|
565
|
+
// as the pin mismatches below: this is a standing property of the machine
|
|
566
|
+
// (a stale tap, a shadowing PATH entry), not something this run did wrong,
|
|
567
|
+
// and it will be replanned. What it must never be is counted as "changed".
|
|
568
|
+
if (outcome.unchanged.length > 0) {
|
|
569
|
+
console.log(
|
|
570
|
+
warnLine(
|
|
571
|
+
`${bold(String(outcome.unchanged.length))} item(s) ran but did not change:`,
|
|
572
|
+
),
|
|
573
|
+
);
|
|
574
|
+
for (const name of outcome.unchanged) {
|
|
575
|
+
const detail = (finishedDetail.get(name) ?? "").replace(
|
|
576
|
+
/^no change — /,
|
|
577
|
+
"",
|
|
578
|
+
);
|
|
579
|
+
console.log(noteLine(`${name}: ${detail}`));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
507
583
|
// An exact pin the installer could not deliver. Reported, never fatal: only
|
|
508
584
|
// the newest published version is installable, so this is a standing property
|
|
509
585
|
// of the manifest rather than something this run did wrong, and failing here
|
package/src/data/marketplaces.ts
CHANGED
|
@@ -52,6 +52,19 @@ export const defaultMarketplaces: Marketplace[] = [
|
|
|
52
52
|
owned: true, // Ours: always listed even when uninstalled, sorted to the top
|
|
53
53
|
// Not featured: a niche channel, so keep it collapsed by default.
|
|
54
54
|
},
|
|
55
|
+
{
|
|
56
|
+
name: "magus-alpha",
|
|
57
|
+
displayName: "Magus Alpha",
|
|
58
|
+
source: {
|
|
59
|
+
source: "github",
|
|
60
|
+
repo: "MadAppGang/magus-alpha",
|
|
61
|
+
},
|
|
62
|
+
description:
|
|
63
|
+
"Experimental plugins with evolving interfaces — may change or be withdrawn without notice",
|
|
64
|
+
official: false,
|
|
65
|
+
owned: true, // Ours: always listed even when uninstalled, sorted to the top
|
|
66
|
+
// Not featured: experimental, so keep it collapsed by default.
|
|
67
|
+
},
|
|
55
68
|
{
|
|
56
69
|
name: "claude-plugins-official",
|
|
57
70
|
displayName: "Anthropic Official",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which profile is in effect for a project, and what it resolves to.
|
|
3
|
+
*
|
|
4
|
+
* `claudeup update` and the TUI must judge a plugin against the SAME pin, or
|
|
5
|
+
* the same plugin in the same project reads differently in each. They used to
|
|
6
|
+
* pick the profile in different ways — the CLI inline, the TUI not at all (it
|
|
7
|
+
* judged everything against "latest") — so an exact pin could never agree.
|
|
8
|
+
* Both now choose through {@link selectProfileId} over the same manifest and
|
|
9
|
+
* the same active-profile symlink.
|
|
10
|
+
*
|
|
11
|
+
* Read-only. Unlike the CLI's `ensureManifest`, nothing here bootstraps a
|
|
12
|
+
* manifest: a screen opening must never author a file a team commits.
|
|
13
|
+
*
|
|
14
|
+
* NOTHING HERE PRINTS — it runs under the CLI's live region and inside the TUI.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
BinRequirement,
|
|
19
|
+
ProfileManifest,
|
|
20
|
+
ResolvedClosure,
|
|
21
|
+
} from "../types/index.js";
|
|
22
|
+
import { readManifest, validateManifest } from "./manifest.js";
|
|
23
|
+
import { resolveProfile } from "./resolver.js";
|
|
24
|
+
import { activeProfile } from "./symlink-manager.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The profile to use: the explicit one, else the active one, else the only one.
|
|
28
|
+
*
|
|
29
|
+
* Each candidate counts only if the manifest defines it. A stale active-profile
|
|
30
|
+
* symlink naming a profile that was since removed would otherwise be chosen and
|
|
31
|
+
* then fail to resolve, even when the manifest has exactly one profile and the
|
|
32
|
+
* answer is unambiguous.
|
|
33
|
+
*/
|
|
34
|
+
export function selectProfileId(
|
|
35
|
+
profileIds: readonly string[],
|
|
36
|
+
explicit: string | undefined,
|
|
37
|
+
active: string | null,
|
|
38
|
+
): string | null {
|
|
39
|
+
if (explicit !== undefined && profileIds.includes(explicit)) return explicit;
|
|
40
|
+
if (active !== null && profileIds.includes(active)) return active;
|
|
41
|
+
if (profileIds.length === 1) return profileIds[0] ?? null;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ActiveClosure {
|
|
46
|
+
profileId: string;
|
|
47
|
+
closure: ResolvedClosure;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ActiveClosureDeps {
|
|
51
|
+
readManifest(projectPath: string): Promise<ProfileManifest>;
|
|
52
|
+
activeProfile(projectPath: string): Promise<string | null>;
|
|
53
|
+
resolveProfile(
|
|
54
|
+
manifest: ProfileManifest,
|
|
55
|
+
id: string,
|
|
56
|
+
opts?: {
|
|
57
|
+
binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
|
|
58
|
+
},
|
|
59
|
+
): Promise<ResolvedClosure>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const REAL_DEPS: ActiveClosureDeps = {
|
|
63
|
+
readManifest,
|
|
64
|
+
activeProfile,
|
|
65
|
+
resolveProfile,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export interface LoadActiveClosureOptions {
|
|
69
|
+
/**
|
|
70
|
+
* false → `resolveProfile` gets a resolver that returns no requirements, so
|
|
71
|
+
* no plugin's `plugin.json` is read and `closure.bins` holds only the
|
|
72
|
+
* profile's own `cliTools` entries. Default true.
|
|
73
|
+
*
|
|
74
|
+
* The Plugins screen needs only `closure.plugins`. The default resolver
|
|
75
|
+
* locates and reads every declared plugin's `plugin.json`, which is work the
|
|
76
|
+
* screen would pay on every fetch for a field it never reads.
|
|
77
|
+
*/
|
|
78
|
+
resolveBins?: boolean;
|
|
79
|
+
deps?: Partial<ActiveClosureDeps>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const NO_BINS = async (): Promise<BinRequirement[]> => [];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The active profile's closure for a project.
|
|
86
|
+
*
|
|
87
|
+
* Null when there is no manifest, an invalid one, or no profile that can be
|
|
88
|
+
* selected without an explicit choice. Never throws: a caller that gets null
|
|
89
|
+
* judges against "latest" and says so, which is better than a screen that fails
|
|
90
|
+
* to load over a profile it only needed for pins.
|
|
91
|
+
*/
|
|
92
|
+
export async function loadActiveClosure(
|
|
93
|
+
projectPath: string,
|
|
94
|
+
opts: LoadActiveClosureOptions = {},
|
|
95
|
+
): Promise<ActiveClosure | null> {
|
|
96
|
+
const deps: ActiveClosureDeps = { ...REAL_DEPS, ...opts.deps };
|
|
97
|
+
try {
|
|
98
|
+
const manifest = await deps.readManifest(projectPath);
|
|
99
|
+
if (validateManifest(manifest).length > 0) return null;
|
|
100
|
+
|
|
101
|
+
const profileId = selectProfileId(
|
|
102
|
+
Object.keys(manifest.profiles),
|
|
103
|
+
undefined,
|
|
104
|
+
await deps.activeProfile(projectPath),
|
|
105
|
+
);
|
|
106
|
+
if (profileId === null) return null;
|
|
107
|
+
|
|
108
|
+
const closure = await deps.resolveProfile(
|
|
109
|
+
manifest,
|
|
110
|
+
profileId,
|
|
111
|
+
opts.resolveBins === false ? { binResolver: NO_BINS } : {},
|
|
112
|
+
);
|
|
113
|
+
return { profileId, closure };
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -35,8 +35,12 @@ import type { MarketplacePlugin } from "./marketplace-catalog.js";
|
|
|
35
35
|
/**
|
|
36
36
|
* How long a cached catalog is served before we ask again.
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* This is the ONLY refresh rhythm claudeup has, and it is why the TUI and
|
|
39
|
+
* `claudeup update` can disagree about the same plugin for up to an hour:
|
|
40
|
+
* `update` fast-forwards the clones and clears this cache before planning,
|
|
41
|
+
* while the TUI serves whatever is here. That is not a bug in either path, but
|
|
42
|
+
* anything that reports "no update" off a cached catalog has to say the answer
|
|
43
|
+
* is up to an hour old.
|
|
40
44
|
*/
|
|
41
45
|
export const CATALOG_TTL_MS = 60 * 60 * 1000;
|
|
42
46
|
|
|
@@ -322,7 +322,7 @@ async function overlayRegistryVersions(
|
|
|
322
322
|
// `readSettings(undefined)` already resolves to the current directory, so the
|
|
323
323
|
// registry lookup has to as well. While it did not, the base map described one
|
|
324
324
|
// project while the overlay refused to match any project's rows at all, and every
|
|
325
|
-
// version was deleted as unbacked — which is why the
|
|
325
|
+
// version was deleted as unbacked — which is why the global
|
|
326
326
|
// plugin view, both of which pass no path, saw project-scope plugins as
|
|
327
327
|
// uninstalled everywhere, not only in a worktree.
|
|
328
328
|
const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
|
|
@@ -677,98 +677,6 @@ export async function saveGlobalInstalledPluginVersion(
|
|
|
677
677
|
await updateInstalledPluginsRegistry(pluginId, version, "user");
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
-
/**
|
|
681
|
-
* Gap-fill: ensure global installedPluginVersions is at least as high as
|
|
682
|
-
* project and local scopes. When a plugin is updated at project scope,
|
|
683
|
-
* global may lag behind, causing Claude Code to resolve stale cache paths.
|
|
684
|
-
*
|
|
685
|
-
* @returns Array of { pluginId, oldVersion, newVersion } for plugins that were bumped
|
|
686
|
-
*/
|
|
687
|
-
export async function gapFillInstalledPluginVersions(
|
|
688
|
-
projectPath?: string,
|
|
689
|
-
): Promise<
|
|
690
|
-
Array<{ pluginId: string; oldVersion: string; newVersion: string }>
|
|
691
|
-
> {
|
|
692
|
-
const globalVersions = await getGlobalInstalledPluginVersions();
|
|
693
|
-
|
|
694
|
-
// Collect versions from project and local scopes
|
|
695
|
-
const allVersions: Record<string, string[]> = {};
|
|
696
|
-
|
|
697
|
-
// Start with global versions
|
|
698
|
-
for (const [id, ver] of Object.entries(globalVersions)) {
|
|
699
|
-
allVersions[id] = [ver];
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// Add project scope versions
|
|
703
|
-
if (projectPath) {
|
|
704
|
-
try {
|
|
705
|
-
const settings = await readSettings(projectPath);
|
|
706
|
-
for (const [id, ver] of Object.entries(
|
|
707
|
-
settings.installedPluginVersions || {},
|
|
708
|
-
)) {
|
|
709
|
-
if (!allVersions[id]) allVersions[id] = [];
|
|
710
|
-
allVersions[id].push(ver);
|
|
711
|
-
}
|
|
712
|
-
} catch {
|
|
713
|
-
/* skip unreadable */
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Add local scope versions
|
|
717
|
-
try {
|
|
718
|
-
const localSettings = await readLocalSettings(projectPath);
|
|
719
|
-
for (const [id, ver] of Object.entries(
|
|
720
|
-
localSettings.installedPluginVersions || {},
|
|
721
|
-
)) {
|
|
722
|
-
if (!allVersions[id]) allVersions[id] = [];
|
|
723
|
-
allVersions[id].push(ver);
|
|
724
|
-
}
|
|
725
|
-
} catch {
|
|
726
|
-
/* skip unreadable */
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// Find plugins where global needs bumping
|
|
731
|
-
const bumped: Array<{
|
|
732
|
-
pluginId: string;
|
|
733
|
-
oldVersion: string;
|
|
734
|
-
newVersion: string;
|
|
735
|
-
}> = [];
|
|
736
|
-
|
|
737
|
-
for (const [pluginId, versions] of Object.entries(allVersions)) {
|
|
738
|
-
// Filter to valid semver versions and sort descending
|
|
739
|
-
const valid = versions.filter((v) => semver.valid(v));
|
|
740
|
-
if (valid.length === 0) continue;
|
|
741
|
-
|
|
742
|
-
const highest = valid.sort((a, b) => semver.rcompare(a, b))[0];
|
|
743
|
-
const globalVer = globalVersions[pluginId];
|
|
744
|
-
|
|
745
|
-
// If global is missing, invalid semver, or lower than the highest, bump it
|
|
746
|
-
if (
|
|
747
|
-
!globalVer ||
|
|
748
|
-
!semver.valid(globalVer) ||
|
|
749
|
-
semver.lt(globalVer, highest)
|
|
750
|
-
) {
|
|
751
|
-
bumped.push({
|
|
752
|
-
pluginId,
|
|
753
|
-
oldVersion: globalVer || "(missing)",
|
|
754
|
-
newVersion: highest,
|
|
755
|
-
});
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Apply bumps in a single write
|
|
760
|
-
if (bumped.length > 0) {
|
|
761
|
-
const settings = await readGlobalSettings();
|
|
762
|
-
settings.installedPluginVersions = settings.installedPluginVersions || {};
|
|
763
|
-
for (const { pluginId, newVersion } of bumped) {
|
|
764
|
-
settings.installedPluginVersions[pluginId] = newVersion;
|
|
765
|
-
}
|
|
766
|
-
await writeGlobalSettings(settings);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
return bumped;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
680
|
export async function removeGlobalInstalledPluginVersion(
|
|
773
681
|
pluginId: string,
|
|
774
682
|
): Promise<void> {
|
|
@@ -1362,86 +1270,6 @@ export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryR
|
|
|
1362
1270
|
return result;
|
|
1363
1271
|
}
|
|
1364
1272
|
|
|
1365
|
-
/**
|
|
1366
|
-
* Clean up extraKnownMarketplaces entries that are now properly registered
|
|
1367
|
-
* in known_marketplaces.json (the official Claude Code marketplace registry).
|
|
1368
|
-
*
|
|
1369
|
-
* extraKnownMarketplaces was the old claudeup install mechanism. The official
|
|
1370
|
-
* way is `claude plugin marketplace add` which writes to known_marketplaces.json.
|
|
1371
|
-
* extraKnownMarketplaces should only be used in project settings for team
|
|
1372
|
-
* marketplace suggestions — not in user settings as a primary install path.
|
|
1373
|
-
*
|
|
1374
|
-
* This function removes magus-related entries from extraKnownMarketplaces in
|
|
1375
|
-
* both user and project settings when they're already in known_marketplaces.json.
|
|
1376
|
-
*/
|
|
1377
|
-
export async function cleanupExtraKnownMarketplaces(
|
|
1378
|
-
projectPath?: string,
|
|
1379
|
-
): Promise<string[]> {
|
|
1380
|
-
const known = await readKnownMarketplaces();
|
|
1381
|
-
const cleaned: string[] = [];
|
|
1382
|
-
|
|
1383
|
-
// Clean user-level settings
|
|
1384
|
-
try {
|
|
1385
|
-
const globalSettings = await readGlobalSettings();
|
|
1386
|
-
if (globalSettings.extraKnownMarketplaces) {
|
|
1387
|
-
let changed = false;
|
|
1388
|
-
for (const name of Object.keys(globalSettings.extraKnownMarketplaces)) {
|
|
1389
|
-
if (known[name]) {
|
|
1390
|
-
delete globalSettings.extraKnownMarketplaces[name];
|
|
1391
|
-
cleaned.push(`user:${name}`);
|
|
1392
|
-
changed = true;
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
// Remove the field entirely if empty
|
|
1396
|
-
if (
|
|
1397
|
-
changed &&
|
|
1398
|
-
Object.keys(globalSettings.extraKnownMarketplaces).length === 0
|
|
1399
|
-
) {
|
|
1400
|
-
// biome-ignore lint/performance/noDelete: the empty block is removed from settings.json entirely; an undefined value would survive Object.keys and `in` checks
|
|
1401
|
-
delete globalSettings.extraKnownMarketplaces;
|
|
1402
|
-
}
|
|
1403
|
-
if (changed) {
|
|
1404
|
-
await writeGlobalSettings(globalSettings);
|
|
1405
|
-
}
|
|
1406
|
-
}
|
|
1407
|
-
} catch {
|
|
1408
|
-
// Non-fatal: user settings cleanup is best-effort
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
// Clean project-level settings
|
|
1412
|
-
if (projectPath) {
|
|
1413
|
-
try {
|
|
1414
|
-
const projectSettings = await readSettings(projectPath);
|
|
1415
|
-
if (projectSettings.extraKnownMarketplaces) {
|
|
1416
|
-
let changed = false;
|
|
1417
|
-
for (const name of Object.keys(
|
|
1418
|
-
projectSettings.extraKnownMarketplaces,
|
|
1419
|
-
)) {
|
|
1420
|
-
if (known[name]) {
|
|
1421
|
-
delete projectSettings.extraKnownMarketplaces[name];
|
|
1422
|
-
cleaned.push(`project:${name}`);
|
|
1423
|
-
changed = true;
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
if (
|
|
1427
|
-
changed &&
|
|
1428
|
-
Object.keys(projectSettings.extraKnownMarketplaces).length === 0
|
|
1429
|
-
) {
|
|
1430
|
-
// biome-ignore lint/performance/noDelete: the empty block is removed from settings.json entirely; an undefined value would survive Object.keys and `in` checks
|
|
1431
|
-
delete projectSettings.extraKnownMarketplaces;
|
|
1432
|
-
}
|
|
1433
|
-
if (changed) {
|
|
1434
|
-
await writeSettings(projectSettings, projectPath);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
} catch {
|
|
1438
|
-
// Non-fatal: project settings cleanup is best-effort
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
return cleaned;
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
1273
|
/**
|
|
1446
1274
|
* Read known_marketplaces.json to get marketplace source info
|
|
1447
1275
|
*/
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Read-only gitignore check for the gitignore manager.
|
|
3
3
|
*
|
|
4
|
-
* Called from
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Called from the TUI on startup and from `doctor`. Resolves the manifest, runs
|
|
5
|
+
* detection, and returns a concise warning when violations exist. Does NOT
|
|
6
|
+
* auto-apply fixes — the user must open the Git State tab and confirm. This
|
|
7
|
+
* mirrors the plugin-version-mismatch flow, where the check only *reports* and
|
|
8
|
+
* the TUI provides the interactive fix dialog.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Violation } from "../types/gitignore.js";
|
|
@@ -13,12 +13,14 @@ import { detectViolations } from "./gitignore-detector.js";
|
|
|
13
13
|
import { formatViolationSummary } from "./gitignore-fixer.js";
|
|
14
14
|
import { resolveGitignore } from "./gitignore-resolver.js";
|
|
15
15
|
|
|
16
|
-
export interface
|
|
16
|
+
export interface GitignoreCheckResult {
|
|
17
17
|
violationCount: number;
|
|
18
18
|
warnings: string[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function checkGitignore(
|
|
21
|
+
export async function checkGitignore(
|
|
22
|
+
cwd: string,
|
|
23
|
+
): Promise<GitignoreCheckResult> {
|
|
22
24
|
const warnings: string[] = [];
|
|
23
25
|
try {
|
|
24
26
|
const resolved = await resolveGitignore(cwd, {
|
|
@@ -38,10 +40,12 @@ export async function checkGitignore(cwd: string): Promise<PrerunResult> {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
|
-
* One-line summary suitable for
|
|
43
|
+
* One-line summary suitable for stderr. Returns null when there's
|
|
42
44
|
* nothing to warn about.
|
|
43
45
|
*/
|
|
44
|
-
export function
|
|
46
|
+
export function formatGitignoreWarning(
|
|
47
|
+
result: GitignoreCheckResult,
|
|
48
|
+
): string | null {
|
|
45
49
|
if (result.violationCount === 0) return null;
|
|
46
50
|
return `⚠ ${result.violationCount} git state issue(s) detected. Run: claudeup → Git State tab to review and fix`;
|
|
47
51
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Each call handles ONE violation; the caller is responsible for confirming
|
|
13
13
|
* each one with the user.
|
|
14
14
|
*
|
|
15
|
-
* `formatViolationSummary` is a pure helper for
|
|
15
|
+
* `formatViolationSummary` is a pure helper for the warning line + TUI.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { spawn } from "node:child_process";
|