magus-cli 7.1.0 → 7.3.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__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/__tests__/stale-load-repair.test.ts +1076 -0
- package/src/__tests__/update-apply.test.ts +146 -1
- package/src/__tests__/update-plan.test.ts +338 -0
- package/src/__tests__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/doctor.ts +128 -2
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +32 -1
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/manifest.ts +21 -0
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -0
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +123 -0
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/hooks/useStaleLoadModal.ts +157 -0
- package/src/ui/renderers/pluginRenderers.tsx +29 -0
- package/src/ui/renderers/pluginStatusText.ts +14 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +46 -13
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +54 -0
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type PluginInfo,
|
|
35
35
|
compareVersions,
|
|
36
36
|
isBehind,
|
|
37
|
+
isRepairablyBehind,
|
|
37
38
|
} from "./plugin-manager.js";
|
|
38
39
|
import {
|
|
39
40
|
type ToolFacts,
|
|
@@ -54,6 +55,23 @@ export type PluginUpdateAction =
|
|
|
54
55
|
| "repair"
|
|
55
56
|
/** Already on the right version. */
|
|
56
57
|
| "current"
|
|
58
|
+
/**
|
|
59
|
+
* BROKEN: the right version is installed, but Claude Code's loader will
|
|
60
|
+
* resolve an OLDER one — so the session runs stale code while every version
|
|
61
|
+
* magus records looks correct.
|
|
62
|
+
*
|
|
63
|
+
* A REINSTALL DOES NOT FIX IT (measured). `claude plugin install` writes a
|
|
64
|
+
* row for this project, and the row that wins belongs to another one — often
|
|
65
|
+
* a worktree that no longer exists, whose registry entry nothing prunes.
|
|
66
|
+
*
|
|
67
|
+
* It IS applied, by `repairStaleLoad` (services/claude-settings.ts), which
|
|
68
|
+
* moves those rows forward onto the installed version rather than reinstalling
|
|
69
|
+
* anything. `target` is therefore the version installed here — the one whose
|
|
70
|
+
* cache directory exists — and not what the catalog offers. `scopes` stays
|
|
71
|
+
* empty because this is not a per-scope install: one registry rewrite covers
|
|
72
|
+
* every row, whichever scope or project each belongs to.
|
|
73
|
+
*/
|
|
74
|
+
| "stale-load"
|
|
57
75
|
/** The catalog check failed; we cannot claim either way. */
|
|
58
76
|
| "unknown";
|
|
59
77
|
|
|
@@ -78,6 +96,8 @@ export type PluginReason =
|
|
|
78
96
|
| "content-stale"
|
|
79
97
|
/** update: a scope is provably behind. */
|
|
80
98
|
| "behind"
|
|
99
|
+
/** stale-load: the loader resolves an older version than the one installed. */
|
|
100
|
+
| "older-version-loads"
|
|
81
101
|
/** current|update: an exact pin differs from what the marketplace offers. */
|
|
82
102
|
| "pin-undeliverable"
|
|
83
103
|
/** current: every installed scope is at (or ahead of) the target. */
|
|
@@ -89,6 +109,16 @@ export interface PluginUpdateItem {
|
|
|
89
109
|
pinned: string;
|
|
90
110
|
/** Highest version installed in any scope, or null when absent. */
|
|
91
111
|
installed: string | null;
|
|
112
|
+
/**
|
|
113
|
+
* The version Claude Code's loader will ACTUALLY load in this project, or
|
|
114
|
+
* null when it will load nothing (and when the registry was unreadable).
|
|
115
|
+
*
|
|
116
|
+
* A separate column from `installed` because they answer different questions
|
|
117
|
+
* and routinely disagree — see services/plugin-resolution.ts. `installed` is
|
|
118
|
+
* what the settings file and this project's registry rows record; `loaded` is
|
|
119
|
+
* what runs.
|
|
120
|
+
*/
|
|
121
|
+
loaded: string | null;
|
|
92
122
|
/** Version the marketplace catalog offers, or null when unknown. */
|
|
93
123
|
available: string | null;
|
|
94
124
|
/** Version this item would move to. Null for `current`/`unknown`. */
|
|
@@ -142,6 +172,40 @@ function highestInstalled(info: PluginInfo | undefined): string | null {
|
|
|
142
172
|
);
|
|
143
173
|
}
|
|
144
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The highest installed version THE PIN PERMITS — the ceiling a stale-load
|
|
177
|
+
* repair may aim at. Null when no installed version qualifies.
|
|
178
|
+
*
|
|
179
|
+
* Under "latest" that is simply {@link highestInstalled}: nothing caps it, and
|
|
180
|
+
* the newest version on this machine is the one to converge on.
|
|
181
|
+
*
|
|
182
|
+
* Under an EXACT pin it is the highest installed version at or below the pin.
|
|
183
|
+
* `installed` reaches across every scope and `scopesNeeding` never downgrades, so
|
|
184
|
+
* a scope sitting above an exact pin is a normal, permanent state — not something
|
|
185
|
+
* to plan toward. Comparing the stale-load branch against the uncapped
|
|
186
|
+
* `installed` made a plugin loading exactly its pinned version read as BROKEN,
|
|
187
|
+
* and the repair then moved the pinned rows off the pin.
|
|
188
|
+
*
|
|
189
|
+
* A version that cannot be ORDERED against the pin (a commit sha, the literal
|
|
190
|
+
* "unknown") is excluded rather than assumed to qualify: "cannot tell" must never
|
|
191
|
+
* license a registry rewrite, which is the same rule {@link isRepairablyBehind}
|
|
192
|
+
* applies one step later.
|
|
193
|
+
*/
|
|
194
|
+
function highestPermittedInstalled(
|
|
195
|
+
info: PluginInfo | undefined,
|
|
196
|
+
pin: string,
|
|
197
|
+
): string | null {
|
|
198
|
+
if (pin === "latest") return highestInstalled(info);
|
|
199
|
+
const permitted = scopeVersions(info)
|
|
200
|
+
.map(([, v]) => v)
|
|
201
|
+
// pin >= v, i.e. v is at or below the pin. Null (unorderable) fails this.
|
|
202
|
+
.filter((v) => (compareVersions(pin, v) ?? -1) >= 0);
|
|
203
|
+
if (permitted.length === 0) return null;
|
|
204
|
+
return permitted.reduce((a, b) =>
|
|
205
|
+
(compareVersions(a, b) ?? -1) >= 0 ? a : b,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
145
209
|
/**
|
|
146
210
|
* Scopes that are strictly BEHIND `target`.
|
|
147
211
|
*
|
|
@@ -277,6 +341,7 @@ export function classifyPlugin(
|
|
|
277
341
|
pluginId,
|
|
278
342
|
pinned: pin,
|
|
279
343
|
installed,
|
|
344
|
+
loaded: info?.loadedVersion ?? null,
|
|
280
345
|
available,
|
|
281
346
|
beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
|
|
282
347
|
Record<PluginScope, string>
|
|
@@ -375,6 +440,112 @@ export function classifyPlugin(
|
|
|
375
440
|
};
|
|
376
441
|
}
|
|
377
442
|
|
|
443
|
+
// Installed and listed — but the loader will resolve an OLDER version than
|
|
444
|
+
// what is installed here, so this session runs stale code.
|
|
445
|
+
//
|
|
446
|
+
// Decided BEFORE every branch below, and that ordering is the whole fix.
|
|
447
|
+
// Those branches compare the REGISTERED version against the catalog, and the
|
|
448
|
+
// registered version is precisely the one that is not loading: a plugin at
|
|
449
|
+
// the newest version in every scope classified `current` while Claude Code
|
|
450
|
+
// loaded a two-versions-old copy. The report was green about code that was
|
|
451
|
+
// not running — which is worse than no report, because magus is the tool
|
|
452
|
+
// relied on to stay current.
|
|
453
|
+
//
|
|
454
|
+
// `loaded` is the loader's own answer (services/plugin-resolution.ts), not
|
|
455
|
+
// magus's registry resolution. Null means we could not establish it, and
|
|
456
|
+
// "cannot tell" never becomes a finding here — the plugin falls through to
|
|
457
|
+
// the ordinary comparisons below.
|
|
458
|
+
//
|
|
459
|
+
// `target` is what is INSTALLED here, never `available`. The repair points
|
|
460
|
+
// registry rows at a cache directory, and only the installed version's cache
|
|
461
|
+
// is on disk — aiming at the catalog version would name a directory that does
|
|
462
|
+
// not exist, which `repairStaleLoad` refuses. When the install is also behind
|
|
463
|
+
// the catalog, the next run plans the ordinary `update` on top, so the two
|
|
464
|
+
// steps converge instead of competing.
|
|
465
|
+
//
|
|
466
|
+
// `scopes` stays empty: one registry rewrite covers every row, so there is no
|
|
467
|
+
// per-scope work to enumerate. `pluginNeedsWork` says so directly.
|
|
468
|
+
//
|
|
469
|
+
// `isRepairablyBehind`, NOT `isBehind` — the one predicate `findStaleLoads`
|
|
470
|
+
// asks too, so this header's count and the startup popup's count are the same
|
|
471
|
+
// question. `isBehind` calls two differing commit shas "behind", which is the
|
|
472
|
+
// right answer to ITS question and the wrong one to this: moving rows forward
|
|
473
|
+
// between two unordered shas is a guess, not a repair. That difference alone
|
|
474
|
+
// made the popup claim 8 broken plugins where this header said 6.
|
|
475
|
+
//
|
|
476
|
+
// INSTALLED against LOADED — never `available` against loaded. This state is
|
|
477
|
+
// "what is installed HERE is not what runs here", and the catalog is not a
|
|
478
|
+
// party to it. Asking the catalog question here was wrong in both directions,
|
|
479
|
+
// measured against this module:
|
|
480
|
+
//
|
|
481
|
+
// - installed 7.1.0, loaded 7.1.0, catalog 7.2.0 — an ordinary pending
|
|
482
|
+
// release, nothing stale at all — classified `stale-load` with an empty
|
|
483
|
+
// `scopes`, so no installer ran, `repairStaleLoad` found no row behind
|
|
484
|
+
// 7.1.0, wrote nothing, and its post-write assertion passed because the
|
|
485
|
+
// loader already resolved 7.1.0. The engine counted that as a success and
|
|
486
|
+
// 7.2.0 was never installed. Every run classified identically while
|
|
487
|
+
// `planIsBehind` stayed true, so `magus update --check` failed forever over
|
|
488
|
+
// something `magus update` could not clear — the un-greenable gate the note
|
|
489
|
+
// on `planIsBehind` argues must not exist. It fired on any machine with a
|
|
490
|
+
// pending release, which is the normal state for this marketplace.
|
|
491
|
+
// - a marketplace publishing NO version (278 of the 292 official plugins)
|
|
492
|
+
// leaves `available` null, so `isRepairablyBehind` answered false and a
|
|
493
|
+
// genuine stale load classified `current` — green about a session running
|
|
494
|
+
// old code, the exact defect this state exists to end.
|
|
495
|
+
//
|
|
496
|
+
// The two formulas agree whenever `available` equals `installed`, which is
|
|
497
|
+
// every machine with nothing pending — which is why it held up by hand and why
|
|
498
|
+
// the parity fixture in stale-load-repair.test.ts had to be changed to be able
|
|
499
|
+
// to fail at all.
|
|
500
|
+
//
|
|
501
|
+
// BOTH AT ONCE — stale-loaded AND behind the catalog — is deliberately NOT
|
|
502
|
+
// folded together, and neither fact is dropped. This branch wins (what runs is
|
|
503
|
+
// the more consequential fact, and a reinstall is measured not to clear it),
|
|
504
|
+
// `target` stays an installed version because only an installed version's cache
|
|
505
|
+
// directory exists, and the pending catalog version is named in the note rather
|
|
506
|
+
// than left for the user to infer from a column. `magus update` then converges
|
|
507
|
+
// over consecutive runs: repair the load, then install the update once the load
|
|
508
|
+
// is no longer stale. It is not collapsed into one run because the two are
|
|
509
|
+
// different mechanisms — a registry rewrite and an installer — and the apply
|
|
510
|
+
// engine runs one action per item; interleaving them silently would make a
|
|
511
|
+
// failed half look like a whole success.
|
|
512
|
+
//
|
|
513
|
+
// CAPPED AT THE PIN, which is why the comparison is against `staleTarget` and
|
|
514
|
+
// not `installed`. `installed` is the highest version in ANY scope, and
|
|
515
|
+
// `scopesNeeding` deliberately never downgrades, so a scope legitimately sits
|
|
516
|
+
// ABOVE an exact pin. Measured: pin 0.3.0, project scope at 0.3.0 and loading,
|
|
517
|
+
// user scope at 0.5.0 — `installed` is 0.5.0, so an uncapped comparison called
|
|
518
|
+
// a plugin loading exactly what its pin asked for a broken load, and the repair
|
|
519
|
+
// then succeeded (this repo's row is writable, and 0.5.0's cache exists because
|
|
520
|
+
// user scope installed it), moving a pinned plugin off its pin and reporting
|
|
521
|
+
// success. An exact pin is a request, not a starting point: this branch may
|
|
522
|
+
// never plan past it, for the same reason the pin branch below refuses to plan
|
|
523
|
+
// toward something the marketplace cannot deliver.
|
|
524
|
+
//
|
|
525
|
+
// A pin naming a version installed NOWHERE at or below it leaves `staleTarget`
|
|
526
|
+
// null and the branch silent. That is the honest answer rather than a quiet
|
|
527
|
+
// promotion: the only versions on disk are above the pin, so there is no repair
|
|
528
|
+
// this may perform, and the pin branch below reports the pin state instead.
|
|
529
|
+
const staleTarget = highestPermittedInstalled(info, pin);
|
|
530
|
+
if (isRepairablyBehind(staleTarget, base.loaded)) {
|
|
531
|
+
// Only ever under "latest". Under an exact pin the catalog is not what this
|
|
532
|
+
// plugin is judged against, so naming a newer published version as something
|
|
533
|
+
// "available once the load is repaired" would advertise a move the pin
|
|
534
|
+
// forbids.
|
|
535
|
+
const pending =
|
|
536
|
+
pin === "latest" && isRepairablyBehind(available, installed);
|
|
537
|
+
return {
|
|
538
|
+
...base,
|
|
539
|
+
target: staleTarget,
|
|
540
|
+
action: "stale-load",
|
|
541
|
+
reason: "older-version-loads",
|
|
542
|
+
scopes: [],
|
|
543
|
+
note: pending
|
|
544
|
+
? `loads v${base.loaded}, installed v${staleTarget}; v${available} is available once the load is repaired`
|
|
545
|
+
: `loads v${base.loaded}, installed v${staleTarget}`,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
378
549
|
// An exact pin is planned against what can ACTUALLY be delivered.
|
|
379
550
|
//
|
|
380
551
|
// This is dependency management, not a package manager: there is no
|
|
@@ -563,6 +734,12 @@ export interface PluginStateCounts {
|
|
|
563
734
|
update: number;
|
|
564
735
|
repair: number;
|
|
565
736
|
current: number;
|
|
737
|
+
/**
|
|
738
|
+
* Its own count, never folded into `current`. The installed version being
|
|
739
|
+
* right is exactly what makes this state dangerous: counted as current, the
|
|
740
|
+
* header would report a clean bill of health for a session running old code.
|
|
741
|
+
*/
|
|
742
|
+
"stale-load": number;
|
|
566
743
|
unknown: number;
|
|
567
744
|
}
|
|
568
745
|
|
|
@@ -575,6 +752,7 @@ export function countPluginStates(
|
|
|
575
752
|
update: 0,
|
|
576
753
|
repair: 0,
|
|
577
754
|
current: 0,
|
|
755
|
+
"stale-load": 0,
|
|
578
756
|
unknown: 0,
|
|
579
757
|
};
|
|
580
758
|
for (const item of items) counts[item.action]++;
|
|
@@ -762,6 +940,14 @@ export function planHasWork(plan: UpdatePlan): boolean {
|
|
|
762
940
|
* binary had no version probe and "upgrade" meant "re-run the installer"; it
|
|
763
941
|
* now means the owning installer says the tool is behind, which is drift.
|
|
764
942
|
*
|
|
943
|
+
* A `stale-load` plugin DOES count. It was excluded while it was report-only, on
|
|
944
|
+
* the un-greenable-gate argument above: nothing magus ran could clear it, so a
|
|
945
|
+
* gate that failed on it would have failed forever. `repairStaleLoad` clears it
|
|
946
|
+
* now, and `magus update` applies that, so the gate closes — and leaving it out
|
|
947
|
+
* would mean `--check` printing "nothing behind" over a plugin whose session
|
|
948
|
+
* loads a version nobody installed, which is the deception the state exists to
|
|
949
|
+
* end.
|
|
950
|
+
*
|
|
765
951
|
* NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
|
|
766
952
|
* this" is not evidence of drift, and most unknowns are structural and
|
|
767
953
|
* permanent rather than transient: a plugin whose marketplace publishes no
|
|
@@ -784,7 +970,8 @@ export function planIsBehind(plan: UpdatePlan): boolean {
|
|
|
784
970
|
(p) =>
|
|
785
971
|
p.action === "install" ||
|
|
786
972
|
p.action === "update" ||
|
|
787
|
-
p.action === "repair"
|
|
973
|
+
p.action === "repair" ||
|
|
974
|
+
p.action === "stale-load",
|
|
788
975
|
) ||
|
|
789
976
|
plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
|
|
790
977
|
plan.skills.some((s) => s.action === "install")
|
package/src/types/index.ts
CHANGED
|
@@ -295,26 +295,33 @@ export interface ClaudeMdConventions {
|
|
|
295
295
|
|
|
296
296
|
// ─── Plugin Profile Types ──────────────────────────────────────────────────────
|
|
297
297
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
/** A profile with its id and scope, as used in UI lists */
|
|
311
|
-
export interface ProfileEntry {
|
|
298
|
+
/**
|
|
299
|
+
* A profile with its id and scope, as used in UI lists.
|
|
300
|
+
*
|
|
301
|
+
* This IS a v2 manifest entry. There is no separate TUI profile shape: both
|
|
302
|
+
* surfaces read one `.claude/profiles.json`, so a second type describing the
|
|
303
|
+
* same file is a second answer to one question. The earlier `Profile` /
|
|
304
|
+
* `ProfilesFile` pair required `createdAt`/`updatedAt` and typed `plugins` as
|
|
305
|
+
* booleans, which is a v1 file — handed a v2 file written by `magus update`,
|
|
306
|
+
* the list sort dereferenced a missing `updatedAt` and threw, so the Profiles
|
|
307
|
+
* tab reported an error for anyone who had ever run the CLI.
|
|
308
|
+
*/
|
|
309
|
+
export interface ProfileEntry extends ProfileManifestEntry {
|
|
312
310
|
id: string;
|
|
313
|
-
name: string;
|
|
314
|
-
plugins: Record<string, boolean>;
|
|
315
|
-
createdAt: string;
|
|
316
|
-
updatedAt: string;
|
|
317
311
|
scope: "user" | "project";
|
|
312
|
+
/**
|
|
313
|
+
* This profile is the one the project is actually running.
|
|
314
|
+
*
|
|
315
|
+
* Derived from the `.claude/settings.json` symlink, not stored — see
|
|
316
|
+
* `activeProfile`. Carried on the entry because a list of profiles with no
|
|
317
|
+
* mark on the live one cannot answer the first question anybody asks of it:
|
|
318
|
+
* the Profiles tab showed two identical-looking rows straight after
|
|
319
|
+
* activating one of them.
|
|
320
|
+
*
|
|
321
|
+
* Only ever true for a `project` profile; a user-scope profile is a template,
|
|
322
|
+
* and nothing links to it.
|
|
323
|
+
*/
|
|
324
|
+
active?: boolean;
|
|
318
325
|
}
|
|
319
326
|
|
|
320
327
|
// NOTE: The built-in profile type lives with its data in
|
|
@@ -324,11 +331,11 @@ export interface ProfileEntry {
|
|
|
324
331
|
|
|
325
332
|
// ─── Team Profile Manifest (v2, committed .claude/profiles.json) ────────────────
|
|
326
333
|
//
|
|
327
|
-
// The
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
334
|
+
// The ONE shape of `.claude/profiles.json`, read by the TUI and the CLI alike.
|
|
335
|
+
// It carries marketplaces, MCP servers, CLI tools, skills, settings and env
|
|
336
|
+
// requirements as well as plugins. A v1 file — the TUI's old plugins-only
|
|
337
|
+
// preset — is a structural subset, so it is read as a v2 entry whose optional
|
|
338
|
+
// fields are simply absent, and rewritten as v2 on the next save.
|
|
332
339
|
|
|
333
340
|
/** A marketplace a profile depends on (registered before its plugins install). */
|
|
334
341
|
export interface ProfileMarketplaceRef {
|
|
@@ -385,6 +392,20 @@ export interface ProfileManifest {
|
|
|
385
392
|
version: 2;
|
|
386
393
|
/** When true, `install --check` fails on any plugin version drift. */
|
|
387
394
|
strictVersions?: boolean;
|
|
395
|
+
/**
|
|
396
|
+
* Which profile this project wants, by id. Committed with the manifest.
|
|
397
|
+
*
|
|
398
|
+
* Without it a manifest says which profiles exist and never which one to
|
|
399
|
+
* use, so every teammate cloning the repo is asked to choose and has nothing
|
|
400
|
+
* to choose by — the repo knows the answer and had no way to write it down.
|
|
401
|
+
*
|
|
402
|
+
* It is intent, not state: the profile that is ACTUALLY active is the symlink
|
|
403
|
+
* (see `activeProfile`), which is per-developer and gitignored. This field is
|
|
404
|
+
* what a fresh clone activates so nobody is asked at all.
|
|
405
|
+
*
|
|
406
|
+
* Must name a profile that exists; `validateManifest` rejects it otherwise.
|
|
407
|
+
*/
|
|
408
|
+
default?: string;
|
|
388
409
|
profiles: Record<string, ProfileManifestEntry>;
|
|
389
410
|
}
|
|
390
411
|
|
package/src/ui/App.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { useRenderer } from "@opentui/react";
|
|
3
3
|
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { activePluginPins } from "../services/active-closure.js";
|
|
4
5
|
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
5
6
|
import {
|
|
7
|
+
findStaleLoads,
|
|
6
8
|
migrateMarketplaceRename,
|
|
7
9
|
recoverMarketplaceSettings,
|
|
10
|
+
repairAllStaleLoads,
|
|
8
11
|
} from "../services/claude-settings.js";
|
|
9
12
|
import { checkGitignore } from "../services/gitignore-check.js";
|
|
10
13
|
import {
|
|
@@ -13,6 +16,11 @@ import {
|
|
|
13
16
|
} from "../services/gitignore-service.js";
|
|
14
17
|
import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
15
18
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
19
|
+
import {
|
|
20
|
+
type ProfileStartupNeed,
|
|
21
|
+
detectProfileStartupNeed,
|
|
22
|
+
needsNothing,
|
|
23
|
+
} from "../services/profile-startup.js";
|
|
16
24
|
import {
|
|
17
25
|
type VersionCheckResult,
|
|
18
26
|
checkForUpdates,
|
|
@@ -22,6 +30,11 @@ import { ProgressBar } from "./components/layout/ProgressBar.js";
|
|
|
22
30
|
import { ModalContainer } from "./components/modals/index.js";
|
|
23
31
|
import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
|
|
24
32
|
import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
|
|
33
|
+
import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
|
|
34
|
+
import {
|
|
35
|
+
staleLoadRepairSummary,
|
|
36
|
+
useStaleLoadModal,
|
|
37
|
+
} from "./hooks/useStaleLoadModal.js";
|
|
25
38
|
import {
|
|
26
39
|
AliasScreen,
|
|
27
40
|
CliToolsScreen,
|
|
@@ -128,6 +141,23 @@ function GlobalKeyHandler({
|
|
|
128
141
|
return;
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
// Ctrl+C quits from ANYWHERE, including from under an open modal, and is
|
|
145
|
+
// checked before the guard below rather than beside `q` further down.
|
|
146
|
+
//
|
|
147
|
+
// Every modal used to be dismissable, so Escape-then-Ctrl+C always worked
|
|
148
|
+
// and this ordering never mattered. The profile gate cannot be dismissed —
|
|
149
|
+
// by design, since there is nothing to carry on to — which left Ctrl+C
|
|
150
|
+
// swallowed by the guard and no interrupt at all. A terminal app that
|
|
151
|
+
// ignores Ctrl+C is indistinguishable from one that has hung.
|
|
152
|
+
//
|
|
153
|
+
// `q` deliberately does NOT get the same treatment: a modal may want it
|
|
154
|
+
// (the select list's own cancel), and it is a printable character an input
|
|
155
|
+
// modal has to receive.
|
|
156
|
+
if (key.ctrl && input === "c") {
|
|
157
|
+
onExit();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
131
161
|
// Don't handle keys when modal is open or searching
|
|
132
162
|
if (state.modal || state.isSearching) return;
|
|
133
163
|
|
|
@@ -364,7 +394,17 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
364
394
|
violationCount: number;
|
|
365
395
|
safeCount: number;
|
|
366
396
|
} | null>(null);
|
|
397
|
+
const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
|
|
398
|
+
null,
|
|
399
|
+
);
|
|
367
400
|
const gitignoreModal = useGitignoreModal();
|
|
401
|
+
const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
|
|
402
|
+
null,
|
|
403
|
+
);
|
|
404
|
+
const staleLoadModal = useStaleLoadModal();
|
|
405
|
+
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
406
|
+
// only non-action is to leave.
|
|
407
|
+
const profileModal = useProfileStartupModal(onExit);
|
|
368
408
|
|
|
369
409
|
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
370
410
|
// the session cache) so the banner reflects a release that lands while
|
|
@@ -407,6 +447,89 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
407
447
|
setGitignoreData(null);
|
|
408
448
|
}, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
|
|
409
449
|
|
|
450
|
+
// Show the broken-plugin modal — same "wait for the modal slot" rule as the
|
|
451
|
+
// gitignore one above, so two startup findings queue instead of one stomping
|
|
452
|
+
// the other.
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (brokenPluginCount === null) return;
|
|
455
|
+
if (state.modal) return;
|
|
456
|
+
staleLoadModal.show({
|
|
457
|
+
brokenCount: brokenPluginCount,
|
|
458
|
+
// Re-surveys before repairing, like the gitignore fixer: the registry may
|
|
459
|
+
// have moved between the startup scan and the user pressing Enter, and
|
|
460
|
+
// repairing from the stale count would act on rows that are no longer
|
|
461
|
+
// there. `repairAllStaleLoads` is the same `repairStaleLoad` the Plugins
|
|
462
|
+
// screen's `a` key drives through the apply engine.
|
|
463
|
+
//
|
|
464
|
+
// The pins are re-read with it, from the same helper the survey used: a
|
|
465
|
+
// repair that capped at a different ceiling than the offer was counted
|
|
466
|
+
// against would move rows the dialog never mentioned.
|
|
467
|
+
onFix: async () =>
|
|
468
|
+
staleLoadRepairSummary(
|
|
469
|
+
await repairAllStaleLoads(
|
|
470
|
+
state.projectPath,
|
|
471
|
+
undefined,
|
|
472
|
+
await activePluginPins(state.projectPath),
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
});
|
|
476
|
+
setBrokenPluginCount(null);
|
|
477
|
+
}, [brokenPluginCount, staleLoadModal, state.modal, state.projectPath]);
|
|
478
|
+
|
|
479
|
+
// Does this project load an older plugin than it installed? Detection only;
|
|
480
|
+
// nothing is written until the user chooses Fix.
|
|
481
|
+
//
|
|
482
|
+
// Its own effect for the same reason the profile check below has one: the
|
|
483
|
+
// marketplace block returns early on `--no-refresh`, which is about network
|
|
484
|
+
// calls, and this question is answered from the local registry alone. Sharing
|
|
485
|
+
// that effect let the flag silently disable the check — observed, while this
|
|
486
|
+
// was still inside it.
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
(async () => {
|
|
489
|
+
try {
|
|
490
|
+
const broken = await findStaleLoads(
|
|
491
|
+
process.cwd(),
|
|
492
|
+
undefined,
|
|
493
|
+
await activePluginPins(process.cwd()),
|
|
494
|
+
);
|
|
495
|
+
if (broken.length > 0) setBrokenPluginCount(broken.length);
|
|
496
|
+
} catch {
|
|
497
|
+
// non-fatal: the Plugins tab still shows the same rows
|
|
498
|
+
}
|
|
499
|
+
})();
|
|
500
|
+
}, []);
|
|
501
|
+
|
|
502
|
+
// Show the profile modal on the same terms as the gitignore one: only once
|
|
503
|
+
// the modal slot is free, so two startup checks that both found something do
|
|
504
|
+
// not stomp on each other. Gitignore is asked first simply because its
|
|
505
|
+
// effect is declared first; whichever the user dismisses, the other follows.
|
|
506
|
+
useEffect(() => {
|
|
507
|
+
if (!profileNeed) return;
|
|
508
|
+
if (state.modal) return;
|
|
509
|
+
profileModal.show(profileNeed);
|
|
510
|
+
setProfileNeed(null);
|
|
511
|
+
}, [profileNeed, profileModal, state.modal]);
|
|
512
|
+
|
|
513
|
+
// Is this project actually running a profile? Detection only — nothing is
|
|
514
|
+
// written until the user picks something in the modal. The Profiles tab could
|
|
515
|
+
// list two profiles while the project ran neither, and nothing anywhere said
|
|
516
|
+
// so.
|
|
517
|
+
//
|
|
518
|
+
// Its own effect, NOT part of the marketplace block below: that one returns
|
|
519
|
+
// early on `--no-refresh`, which is about network calls to marketplaces and
|
|
520
|
+
// has no bearing on whether this project is on a profile. Sharing the effect
|
|
521
|
+
// would let that flag silently disable this question.
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
(async () => {
|
|
524
|
+
try {
|
|
525
|
+
const need = await detectProfileStartupNeed(process.cwd());
|
|
526
|
+
if (!needsNothing(need)) setProfileNeed(need);
|
|
527
|
+
} catch {
|
|
528
|
+
// non-fatal: the Profiles tab still works by hand
|
|
529
|
+
}
|
|
530
|
+
})();
|
|
531
|
+
}, []);
|
|
532
|
+
|
|
410
533
|
// Auto-refresh marketplaces on startup
|
|
411
534
|
useEffect(() => {
|
|
412
535
|
const noRefresh = process.argv.includes("--no-refresh");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
+
import { hintChip } from "../../theme-mode.js";
|
|
2
3
|
import { theme } from "../../theme.js";
|
|
3
4
|
|
|
4
5
|
/** One footer hint: a key (or key group) and the action it performs. */
|
|
@@ -28,11 +29,12 @@ export function joinKeys(keys: string[]): string {
|
|
|
28
29
|
// The shared chip palette, not a local one. This file used to define its own
|
|
29
30
|
// (`theme.colors.dim` block + terminal-foreground ink), which is why the footer
|
|
30
31
|
// stayed a low-contrast grey-on-grey strip after the palette was made vivid —
|
|
31
|
-
// the new colours simply never reached it. Anything chip-shaped uses theme.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// the new colours simply never reached it. Anything chip-shaped uses the theme.
|
|
33
|
+
//
|
|
34
|
+
// `hintChip()` rather than `theme.hints`: the chip is two fills that must sit a
|
|
35
|
+
// measured step apart, and "a step quieter" is only definable against a page.
|
|
36
|
+
// The static palette cannot express it; `theme-mode` resolves it.
|
|
37
|
+
const GAP = " ";
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* Render a row of footer hints as key badges + labels:
|
|
@@ -45,25 +47,35 @@ const GAP = " ";
|
|
|
45
47
|
export function FooterHints({
|
|
46
48
|
hints,
|
|
47
49
|
}: { hints: FooterHint[] }): React.ReactNode {
|
|
50
|
+
const ink = hintChip();
|
|
48
51
|
const nodes: React.ReactNode[] = [];
|
|
49
52
|
hints.forEach((hint, i) => {
|
|
50
53
|
// A hint is identified by its key chord plus label, not its position.
|
|
51
54
|
const id = `${joinKeys(hint.keys)}:${hint.label}`;
|
|
52
55
|
if (i > 0) {
|
|
53
56
|
nodes.push(
|
|
54
|
-
<span key={`gap-${id}`} fg={
|
|
57
|
+
<span key={`gap-${id}`} fg={theme.colors.muted}>
|
|
55
58
|
{GAP}
|
|
56
59
|
</span>,
|
|
57
60
|
);
|
|
58
61
|
}
|
|
62
|
+
// The two halves butt together with no separator: the key's fill ends
|
|
63
|
+
// exactly where the label's begins, so the pair reads as one chip with a
|
|
64
|
+
// bright end rather than as a badge followed by a word.
|
|
65
|
+
//
|
|
66
|
+
// Both halves are padded on BOTH sides. The label used to carry a trailing
|
|
67
|
+
// space only, which left its first letter hard against the boundary between
|
|
68
|
+
// the two fills — the key's own trailing space sits on the bright fill, so
|
|
69
|
+
// there was no quiet gap on the label's side at all and `↑↓ select` read as
|
|
70
|
+
// one cramped word.
|
|
59
71
|
nodes.push(
|
|
60
|
-
<span key={`k-${id}`} bg={
|
|
72
|
+
<span key={`k-${id}`} bg={ink.keyBg} fg={ink.keyFg}>
|
|
61
73
|
{` ${joinKeys(hint.keys)} `}
|
|
62
74
|
</span>,
|
|
63
75
|
);
|
|
64
76
|
nodes.push(
|
|
65
|
-
<span key={`l-${id}`} fg={
|
|
66
|
-
{` ${hint.label}`}
|
|
77
|
+
<span key={`l-${id}`} bg={ink.labelBg} fg={ink.labelFg}>
|
|
78
|
+
{` ${hint.label} `}
|
|
67
79
|
</span>,
|
|
68
80
|
);
|
|
69
81
|
});
|