magus-cli 7.5.2 → 7.5.4
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-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +17 -22
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +152 -164
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -387,9 +387,16 @@ function describeLoadForward(outcome: StaleLoadRepairResult): string {
|
|
|
387
387
|
/**
|
|
388
388
|
* Scopes the apply loop would touch for `item`. Empty means it is skipped.
|
|
389
389
|
*
|
|
390
|
-
* A plugin the profile declares but nothing has installed
|
|
391
|
-
* scope, matching `install`: a profile is a property of one
|
|
392
|
-
* plugins must not be enabled machine-wide.
|
|
390
|
+
* A plugin the profile declares but nothing has installed (reason `absent`)
|
|
391
|
+
* goes in at PROJECT scope, matching `install`: a profile is a property of one
|
|
392
|
+
* repo, so its plugins must not be enabled machine-wide.
|
|
393
|
+
*
|
|
394
|
+
* A plugin whose files are missing is reinstalled exactly where it is
|
|
395
|
+
* installed — the plan's `scopes`. Every install used to go to project scope,
|
|
396
|
+
* so a user-scope plugin with its files missing was installed with `--scope
|
|
397
|
+
* project`, which turns it on in the project's settings.json; for a plugin the
|
|
398
|
+
* profile does not declare, the reconcile after the run then absorbed that
|
|
399
|
+
* record into the team's committed profiles.json.
|
|
393
400
|
*
|
|
394
401
|
* Every scope holding an outdated install is returned, not just one. Fixing a
|
|
395
402
|
* single hardcoded scope does not converge: it reports success while the
|
|
@@ -408,7 +415,9 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
|
408
415
|
item.action === "stale-load"
|
|
409
416
|
)
|
|
410
417
|
return [];
|
|
411
|
-
return item.action === "install"
|
|
418
|
+
return item.action === "install" && item.reason === "absent"
|
|
419
|
+
? ["project"]
|
|
420
|
+
: item.scopes;
|
|
412
421
|
}
|
|
413
422
|
|
|
414
423
|
/**
|
|
@@ -451,8 +460,11 @@ export function pluginNeedsWork(item: PluginUpdateItem): boolean {
|
|
|
451
460
|
* report "0.2.4 installed, 0.3.0 available" while owning no row of its own.
|
|
452
461
|
* That is exactly the shape that used to reach `update`.
|
|
453
462
|
*
|
|
454
|
-
* `readInstalled`
|
|
455
|
-
* which is what makes it the
|
|
463
|
+
* `readInstalled` reads this project's own row at `scope` by exact path — no
|
|
464
|
+
* worktree inheritance and no user-row fallback — which is what makes it the
|
|
465
|
+
* right question to ask here. It used to fall back to the user row, so a plugin
|
|
466
|
+
* installed only at user scope and turned on in project settings passed this
|
|
467
|
+
* check and reached `update --scope project` from a folder with no project row.
|
|
456
468
|
*/
|
|
457
469
|
async function advanceInScope(
|
|
458
470
|
deps: PluginApplyDeps,
|
|
@@ -487,9 +499,14 @@ async function advanceInScope(
|
|
|
487
499
|
* No installer output is parsed — "already at the latest version" is
|
|
488
500
|
* locale- and version-fragile. The registry row the apply loop already reads
|
|
489
501
|
* back is compared with the before-version the plan carries:
|
|
490
|
-
* - update:
|
|
502
|
+
* - update: no touched scope recorded a version it did not hold before;
|
|
491
503
|
* - install: no touched scope reads back any version at all;
|
|
492
504
|
* - repair: never — same version is its definition; the files moved.
|
|
505
|
+
*
|
|
506
|
+
* A null read-back is nothing recorded, so it is never a move. It used to
|
|
507
|
+
* count as one — null differs from any before-version — and since the
|
|
508
|
+
* read-back matches this project's own row exactly, an update that recorded
|
|
509
|
+
* nothing reported "project → <target>" on every run while nothing changed.
|
|
493
510
|
*/
|
|
494
511
|
function pluginNoMove(
|
|
495
512
|
item: PluginUpdateItem,
|
|
@@ -497,10 +514,11 @@ function pluginNoMove(
|
|
|
497
514
|
): string | null {
|
|
498
515
|
if (landed.length === 0) return null;
|
|
499
516
|
if (item.action === "update") {
|
|
500
|
-
const
|
|
501
|
-
([scope, actual]) =>
|
|
517
|
+
const moved = landed.some(
|
|
518
|
+
([scope, actual]) =>
|
|
519
|
+
actual !== null && actual !== item.beforeVersions[scope],
|
|
502
520
|
);
|
|
503
|
-
if (
|
|
521
|
+
if (moved) return null;
|
|
504
522
|
const v = landed.find(([, a]) => a)?.[1];
|
|
505
523
|
return v
|
|
506
524
|
? `the installer exited 0 but ${item.pluginId} is still ${v}`
|
|
@@ -34,7 +34,11 @@ import type { PluginScope } from "./claude-cli.js";
|
|
|
34
34
|
import {
|
|
35
35
|
type PluginInfo,
|
|
36
36
|
compareVersions,
|
|
37
|
+
filesMissingScopes,
|
|
38
|
+
highestInstalledVersion,
|
|
39
|
+
installedScopeVersions,
|
|
37
40
|
isBehind,
|
|
41
|
+
isInstalledAnywhere,
|
|
38
42
|
isRepairablyBehind,
|
|
39
43
|
} from "./plugin-manager.js";
|
|
40
44
|
import {
|
|
@@ -48,7 +52,7 @@ import {
|
|
|
48
52
|
// ── Plugins ──────────────────────────────────────────────────────────────────
|
|
49
53
|
|
|
50
54
|
export type PluginUpdateAction =
|
|
51
|
-
/** Declared by the profile, installed
|
|
55
|
+
/** Declared by the profile and installed nowhere, or installed with its files missing. */
|
|
52
56
|
| "install"
|
|
53
57
|
/** Installed at a version other than the one we should be on. */
|
|
54
58
|
| "update"
|
|
@@ -83,8 +87,8 @@ export type PluginUpdateAction =
|
|
|
83
87
|
export type PluginReason =
|
|
84
88
|
/** install: declared by the profile, installed nowhere. */
|
|
85
89
|
| "absent"
|
|
86
|
-
/** install:
|
|
87
|
-
| "
|
|
90
|
+
/** install: installed at a scope, but no version resolves at any installed scope. */
|
|
91
|
+
| "files-missing"
|
|
88
92
|
/** unknown: a marketplace we READ does not list it. */
|
|
89
93
|
| "not-offered"
|
|
90
94
|
/** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
|
|
@@ -127,7 +131,11 @@ export interface PluginUpdateItem {
|
|
|
127
131
|
action: PluginUpdateAction;
|
|
128
132
|
/** Why it is in this state. */
|
|
129
133
|
reason: PluginReason;
|
|
130
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Scopes holding an install this action must touch — for `files-missing`, the
|
|
136
|
+
* scopes installed with no version. Empty for an `absent` install, which the
|
|
137
|
+
* engine puts at project scope.
|
|
138
|
+
*/
|
|
131
139
|
scopes: PluginScope[];
|
|
132
140
|
/**
|
|
133
141
|
* The version installed in each scope BEFORE any work, so an apply can tell
|
|
@@ -141,44 +149,12 @@ export interface PluginUpdateItem {
|
|
|
141
149
|
note?: string;
|
|
142
150
|
}
|
|
143
151
|
|
|
144
|
-
/** Scope → installed version, for the three scopes a PluginInfo carries. */
|
|
145
|
-
function scopeVersions(
|
|
146
|
-
info: PluginInfo | undefined,
|
|
147
|
-
): Array<[PluginScope, string]> {
|
|
148
|
-
if (!info) return [];
|
|
149
|
-
const out: Array<[PluginScope, string]> = [];
|
|
150
|
-
for (const [scope, status] of [
|
|
151
|
-
["user", info.userScope],
|
|
152
|
-
["project", info.projectScope],
|
|
153
|
-
["local", info.localScope],
|
|
154
|
-
] as const) {
|
|
155
|
-
if (status?.version) out.push([scope, status.version]);
|
|
156
|
-
}
|
|
157
|
-
return out;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The highest version installed across scopes, or null.
|
|
162
|
-
*
|
|
163
|
-
* `compareVersions` answers null for an unorderable pair, and null is not
|
|
164
|
-
* `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
|
|
165
|
-
* ranked. "Highest" is not meaningful across commit ids; picking one
|
|
166
|
-
* deterministically is, and every caller only needs *an* installed version.
|
|
167
|
-
*/
|
|
168
|
-
function highestInstalled(info: PluginInfo | undefined): string | null {
|
|
169
|
-
const versions = scopeVersions(info).map(([, v]) => v);
|
|
170
|
-
if (versions.length === 0) return null;
|
|
171
|
-
return versions.reduce((a, b) =>
|
|
172
|
-
(compareVersions(a, b) ?? -1) >= 0 ? a : b,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
152
|
/**
|
|
177
153
|
* The highest installed version THE PIN PERMITS — the ceiling a stale-load
|
|
178
154
|
* repair may aim at. Null when no installed version qualifies.
|
|
179
155
|
*
|
|
180
|
-
* Under "latest" that is simply {@link
|
|
181
|
-
* the newest version on this machine is the one to converge on.
|
|
156
|
+
* Under "latest" that is simply {@link highestInstalledVersion}: nothing caps
|
|
157
|
+
* it, and the newest version on this machine is the one to converge on.
|
|
182
158
|
*
|
|
183
159
|
* Under an EXACT pin it is the highest installed version at or below the pin.
|
|
184
160
|
* `installed` reaches across every scope and `scopesNeeding` never downgrades, so
|
|
@@ -196,8 +172,8 @@ function highestPermittedInstalled(
|
|
|
196
172
|
info: PluginInfo | undefined,
|
|
197
173
|
pin: string,
|
|
198
174
|
): string | null {
|
|
199
|
-
if (pin === "latest") return
|
|
200
|
-
const permitted =
|
|
175
|
+
if (pin === "latest") return highestInstalledVersion(info);
|
|
176
|
+
const permitted = installedScopeVersions(info)
|
|
201
177
|
.map(([, v]) => v)
|
|
202
178
|
// pin >= v, i.e. v is at or below the pin. Null (unorderable) fails this.
|
|
203
179
|
.filter((v) => (compareVersions(pin, v) ?? -1) >= 0);
|
|
@@ -238,7 +214,7 @@ export function scopesNeeding(
|
|
|
238
214
|
// A raw `compareVersions(...) > 0` reported false for that pair and the
|
|
239
215
|
// scope was silently left alone. An unknown (null) is still left alone —
|
|
240
216
|
// moving a scope we cannot rank would be a guess.
|
|
241
|
-
return
|
|
217
|
+
return installedScopeVersions(info)
|
|
242
218
|
.filter(([, version]) => isBehind(target, version) === true)
|
|
243
219
|
.map(([scope]) => scope);
|
|
244
220
|
}
|
|
@@ -255,11 +231,11 @@ function marketplaceSuffix(id: string): string | null {
|
|
|
255
231
|
* The catalog, indexed for the questions the classifier asks of it.
|
|
256
232
|
*
|
|
257
233
|
* "Listed" is narrower than "present". `getAvailablePlugins` puts ORPHANS —
|
|
258
|
-
*
|
|
259
|
-
* list with `isOrphaned: true`, so a plain `has(id)` is
|
|
260
|
-
* nothing publishes. Treating an orphan as listed is
|
|
261
|
-
* `current`: its `version` is filled in from its own
|
|
262
|
-
* compare found them equal.
|
|
234
|
+
* in this project's settings or registry, but published by no marketplace we
|
|
235
|
+
* read — into the same list with `isOrphaned: true`, so a plain `has(id)` is
|
|
236
|
+
* true for a plugin that nothing publishes. Treating an orphan as listed is
|
|
237
|
+
* what let an orphan plan as `current`: its `version` is filled in from its own
|
|
238
|
+
* installed version, so the compare found them equal.
|
|
263
239
|
*/
|
|
264
240
|
export interface CatalogIndex {
|
|
265
241
|
get(pluginId: string): PluginInfo | undefined;
|
|
@@ -306,15 +282,6 @@ export function indexCatalog(
|
|
|
306
282
|
};
|
|
307
283
|
}
|
|
308
284
|
|
|
309
|
-
/** True when a settings file enables the plugin in at least one scope. */
|
|
310
|
-
function enabledAnywhere(info: PluginInfo | undefined): boolean {
|
|
311
|
-
return (
|
|
312
|
-
!!info?.userScope?.enabled ||
|
|
313
|
-
!!info?.projectScope?.enabled ||
|
|
314
|
-
!!info?.localScope?.enabled
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
285
|
/**
|
|
319
286
|
* THE plugin classifier. Pure.
|
|
320
287
|
*
|
|
@@ -332,7 +299,7 @@ export function classifyPlugin(
|
|
|
332
299
|
): PluginUpdateItem {
|
|
333
300
|
const info = index.get(pluginId);
|
|
334
301
|
const listed = index.isListed(pluginId);
|
|
335
|
-
const installed =
|
|
302
|
+
const installed = highestInstalledVersion(info);
|
|
336
303
|
// An orphan's `version` is its own installed version (or a stale local
|
|
337
304
|
// clone's), not something a marketplace we read offers — so it is not
|
|
338
305
|
// "available" from anywhere.
|
|
@@ -344,7 +311,7 @@ export function classifyPlugin(
|
|
|
344
311
|
installed,
|
|
345
312
|
loaded: info?.loadedVersion ?? null,
|
|
346
313
|
available,
|
|
347
|
-
beforeVersions: Object.fromEntries(
|
|
314
|
+
beforeVersions: Object.fromEntries(installedScopeVersions(info)) as Partial<
|
|
348
315
|
Record<PluginScope, string>
|
|
349
316
|
>,
|
|
350
317
|
};
|
|
@@ -388,24 +355,29 @@ export function classifyPlugin(
|
|
|
388
355
|
};
|
|
389
356
|
}
|
|
390
357
|
|
|
391
|
-
//
|
|
358
|
+
// No installed scope has a version — the "checked but not installed" case.
|
|
392
359
|
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
360
|
+
// So a scope that IS installed here has its files missing. It is tested
|
|
361
|
+
// directly rather than through `hasMissingFiles`, which excludes orphans: an
|
|
362
|
+
// orphan that is only a settings key would otherwise read as `absent`, and the
|
|
363
|
+
// TUI renders `absent` as "the profile declares it", which an undeclared
|
|
364
|
+
// orphan does not.
|
|
365
|
+
//
|
|
366
|
+
// Files missing carries the scopes to reinstall at: exactly the ones installed
|
|
367
|
+
// with no version. It used to carry none, and the engine read "install" as
|
|
368
|
+
// "project scope" — so a user-scope plugin with its files missing was
|
|
369
|
+
// reinstalled at project scope, turning it on in the team's settings.json.
|
|
370
|
+
// `absent` still carries none: the engine installs it at project scope.
|
|
398
371
|
if (!installed) {
|
|
399
|
-
const
|
|
372
|
+
const missingAt = filesMissingScopes(info);
|
|
373
|
+
const filesMissing = missingAt.length > 0;
|
|
400
374
|
return {
|
|
401
375
|
...base,
|
|
402
376
|
target: pin === "latest" ? available : pin,
|
|
403
377
|
action: "install",
|
|
404
|
-
reason:
|
|
405
|
-
scopes:
|
|
406
|
-
...(
|
|
407
|
-
? { note: "enabled in settings, but no files on disk" }
|
|
408
|
-
: {}),
|
|
378
|
+
reason: filesMissing ? "files-missing" : "absent",
|
|
379
|
+
scopes: missingAt,
|
|
380
|
+
...(filesMissing ? { note: "files missing" } : {}),
|
|
409
381
|
};
|
|
410
382
|
}
|
|
411
383
|
|
|
@@ -622,7 +594,7 @@ export function classifyPlugin(
|
|
|
622
594
|
target: installed,
|
|
623
595
|
action: "repair",
|
|
624
596
|
reason: "content-stale",
|
|
625
|
-
scopes:
|
|
597
|
+
scopes: installedScopeVersions(info).map(([scope]) => scope),
|
|
626
598
|
note: "files changed without a version bump",
|
|
627
599
|
};
|
|
628
600
|
}
|
|
@@ -669,17 +641,38 @@ export function classifyPlugin(
|
|
|
669
641
|
}
|
|
670
642
|
|
|
671
643
|
/**
|
|
672
|
-
*
|
|
644
|
+
* THE plugin plan — for `magus update` and the Plugins screen alike.
|
|
645
|
+
*
|
|
646
|
+
* Every plugin the active profile declares, under its pin, and every plugin
|
|
647
|
+
* installed in any scope (user, project, local), under "latest" — the rule
|
|
648
|
+
* {@link pinForRow} states — each classified by {@link classifyPlugin}, sorted.
|
|
649
|
+
*
|
|
650
|
+
* ONE list, read by both surfaces: the screen's rows, header counts and
|
|
651
|
+
* "update all", and the CLI's report and apply. It used to be two — the CLI
|
|
652
|
+
* planned the profile's plugins only, the screen every installed plugin — so a
|
|
653
|
+
* USER-scope plugin the screen showed as "update available" was never seen by
|
|
654
|
+
* `magus update`, which then reported everything up to date. The owner asked
|
|
655
|
+
* for "absolutely the same code" on both sides; this is it.
|
|
673
656
|
*
|
|
674
657
|
* @param pinned closure.plugins — pluginId -> exact version | "latest"
|
|
675
|
-
* @param catalog getAvailablePlugins() output
|
|
658
|
+
* @param catalog getAvailablePlugins() output — keyed by id, or as a list
|
|
676
659
|
*/
|
|
677
660
|
export function planPluginUpdates(
|
|
678
|
-
pinned: Record<string, string
|
|
679
|
-
catalog:
|
|
661
|
+
pinned: Readonly<Record<string, string>>,
|
|
662
|
+
catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
|
|
680
663
|
): PluginUpdateItem[] {
|
|
681
664
|
const index = indexCatalog(catalog);
|
|
682
|
-
|
|
665
|
+
const plugins =
|
|
666
|
+
catalog instanceof Map
|
|
667
|
+
? [...catalog.values()]
|
|
668
|
+
: (catalog as readonly PluginInfo[]);
|
|
669
|
+
const pins = new Map(Object.entries(pinned));
|
|
670
|
+
for (const info of plugins) {
|
|
671
|
+
if (pins.has(info.id)) continue;
|
|
672
|
+
const pin = pinForRow(info, pinned);
|
|
673
|
+
if (pin !== null) pins.set(info.id, pin);
|
|
674
|
+
}
|
|
675
|
+
return [...pins]
|
|
683
676
|
.map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
|
|
684
677
|
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
685
678
|
}
|
|
@@ -689,45 +682,36 @@ export function planPluginUpdates(
|
|
|
689
682
|
*
|
|
690
683
|
* 1. The active profile's pin, when it declares the plugin — the SAME value
|
|
691
684
|
* `magus update` plans with, which is what makes the two agree.
|
|
692
|
-
* 2. Otherwise "latest", when the plugin is installed in any scope
|
|
693
|
-
*
|
|
694
|
-
* the row and the action agree.
|
|
685
|
+
* 2. Otherwise "latest", when the plugin is installed in any scope, files
|
|
686
|
+
* missing included — what the TUI's own update actions have always planned
|
|
687
|
+
* with, so the row and the action agree.
|
|
695
688
|
* 3. Otherwise null: an undeclared catalog entry the user is only browsing. It
|
|
696
689
|
* gets no status at all. `install` means "the profile wants it", and an
|
|
697
690
|
* undeclared plugin carries no such claim.
|
|
698
691
|
*
|
|
699
692
|
* A consequence the renderer relies on: reason `absent` can only come from
|
|
700
|
-
* rule 1, because rule 2 never assigns a pin to a row that is
|
|
701
|
-
* nor enabled.
|
|
693
|
+
* rule 1, because rule 2 never assigns a pin to a row that is not installed.
|
|
702
694
|
*/
|
|
703
695
|
export function pinForRow(
|
|
704
696
|
info: PluginInfo,
|
|
705
697
|
pins: Readonly<Record<string, string>>,
|
|
706
698
|
): string | null {
|
|
707
699
|
if (Object.hasOwn(pins, info.id)) return pins[info.id] ?? null;
|
|
708
|
-
if (
|
|
700
|
+
if (isInstalledAnywhere(info)) return "latest";
|
|
709
701
|
return null;
|
|
710
702
|
}
|
|
711
703
|
|
|
712
704
|
/**
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
* so the row, the detail panel, the header and the scope picker cannot disagree.
|
|
705
|
+
* {@link planPluginUpdates} keyed by plugin id, for the screen's lookups — the
|
|
706
|
+
* SAME plan `magus update` runs, not a second classification. Computed once
|
|
707
|
+
* per data/pins change, and the one map feeds the row, the detail panel, the
|
|
708
|
+
* header and the scope picker, so none of them can disagree.
|
|
718
709
|
*/
|
|
719
710
|
export function classifyPluginRows(
|
|
720
711
|
plugins: readonly PluginInfo[],
|
|
721
712
|
pins: Readonly<Record<string, string>>,
|
|
722
713
|
): Map<string, PluginUpdateItem> {
|
|
723
|
-
|
|
724
|
-
const out = new Map<string, PluginUpdateItem>();
|
|
725
|
-
for (const info of plugins) {
|
|
726
|
-
const pin = pinForRow(info, pins);
|
|
727
|
-
if (pin === null) continue;
|
|
728
|
-
out.set(info.id, classifyPlugin(info.id, pin, index));
|
|
729
|
-
}
|
|
730
|
-
return out;
|
|
714
|
+
return new Map(planPluginUpdates(pins, plugins).map((i) => [i.pluginId, i]));
|
|
731
715
|
}
|
|
732
716
|
|
|
733
717
|
export interface PluginStateCounts {
|
package/src/ui/App.tsx
CHANGED
|
@@ -7,10 +7,13 @@ import {
|
|
|
7
7
|
recoverMarketplaceSettings,
|
|
8
8
|
repairAllStaleLoads,
|
|
9
9
|
} from "../services/claude-settings.js";
|
|
10
|
-
import { checkGitignore } from "../services/gitignore-check.js";
|
|
11
10
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
type StartupGitignoreNeed,
|
|
12
|
+
startupGitignoreNeed,
|
|
13
|
+
} from "../services/gitignore-check.js";
|
|
14
|
+
import {
|
|
15
|
+
applyStartupFixes,
|
|
16
|
+
describeStartupFix,
|
|
14
17
|
} from "../services/gitignore-service.js";
|
|
15
18
|
import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
16
19
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
@@ -261,10 +264,8 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
261
264
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
262
265
|
const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
|
|
263
266
|
const [profileNotices, setProfileNotices] = useState<string[]>([]);
|
|
264
|
-
const [gitignoreData, setGitignoreData] =
|
|
265
|
-
|
|
266
|
-
safeCount: number;
|
|
267
|
-
} | null>(null);
|
|
267
|
+
const [gitignoreData, setGitignoreData] =
|
|
268
|
+
useState<StartupGitignoreNeed | null>(null);
|
|
268
269
|
const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
|
|
269
270
|
null,
|
|
270
271
|
);
|
|
@@ -273,9 +274,22 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
273
274
|
null,
|
|
274
275
|
);
|
|
275
276
|
const staleLoadModal = useStaleLoadModal();
|
|
277
|
+
// The gitignore question, asked fresh: on every open, and again once the
|
|
278
|
+
// profile modal has activated a profile — a first adoption is what turns a
|
|
279
|
+
// committed settings.json into a generated file git should not track.
|
|
280
|
+
// Nothing remembers an earlier answer.
|
|
281
|
+
const checkGitignoreNow = React.useCallback(() => {
|
|
282
|
+
startupGitignoreNeed(process.cwd())
|
|
283
|
+
.then((need) => {
|
|
284
|
+
if (need) setGitignoreData(need);
|
|
285
|
+
})
|
|
286
|
+
.catch(() => {
|
|
287
|
+
// non-fatal: the Git State tab still shows the same rows
|
|
288
|
+
});
|
|
289
|
+
}, []);
|
|
276
290
|
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
277
291
|
// only non-action is to leave.
|
|
278
|
-
const profileModal = useProfileStartupModal(onExit);
|
|
292
|
+
const profileModal = useProfileStartupModal(onExit, checkGitignoreNow);
|
|
279
293
|
|
|
280
294
|
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
281
295
|
// the session cache) so the banner reflects a release that lands while
|
|
@@ -318,10 +332,11 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
318
332
|
gitignoreModal.show({
|
|
319
333
|
violationCount: gitignoreData.violationCount,
|
|
320
334
|
safeCount: gitignoreData.safeCount,
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
335
|
+
generated: gitignoreData.generated,
|
|
336
|
+
// In place, then back to the app: tracked generated files are
|
|
337
|
+
// untracked, missing entries added; other items stay for the tab.
|
|
338
|
+
onFixSafe: async () =>
|
|
339
|
+
describeStartupFix(await applyStartupFixes(state.projectPath)),
|
|
325
340
|
});
|
|
326
341
|
setGitignoreData(null);
|
|
327
342
|
}, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
|
|
@@ -389,6 +404,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
389
404
|
setProfileNeed(null);
|
|
390
405
|
}, [profileNeed, profileModal, state.modal]);
|
|
391
406
|
|
|
407
|
+
// The gitignore question on every open. Its own effect, NOT part of the
|
|
408
|
+
// marketplace block below, which returns early on `--no-refresh`: that flag
|
|
409
|
+
// is about network calls and must not silence this question.
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
checkGitignoreNow();
|
|
412
|
+
}, [checkGitignoreNow]);
|
|
413
|
+
|
|
392
414
|
// Is this project actually running a profile? Detection only — nothing is
|
|
393
415
|
// written until the user picks something in the modal. The Profiles tab could
|
|
394
416
|
// list two profiles while the project ran neither, and nothing anywhere said
|
|
@@ -405,8 +427,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
405
427
|
// edits into the active profile, regenerate from a changed
|
|
406
428
|
// profiles.json, restore a lost marker — then the question, if one
|
|
407
429
|
// is left to ask.
|
|
408
|
-
const { need, notices } = await prepareProfileStartup(
|
|
409
|
-
|
|
430
|
+
const { need, notices, problems } = await prepareProfileStartup(
|
|
431
|
+
process.cwd(),
|
|
432
|
+
);
|
|
433
|
+
// A refusal never blocks the TUI at startup: it is shown beside what
|
|
434
|
+
// the gate did, and the user is still asked whatever is left.
|
|
435
|
+
const shown = [...notices, ...problems];
|
|
436
|
+
if (shown.length > 0) setProfileNotices(shown);
|
|
410
437
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
411
438
|
} catch {
|
|
412
439
|
// non-fatal: the Profiles tab still works by hand
|
|
@@ -470,27 +497,6 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
470
497
|
})
|
|
471
498
|
.catch(() => {}); // non-fatal
|
|
472
499
|
|
|
473
|
-
// Check for gitignore manifest violations and surface a popup if any.
|
|
474
|
-
// Loads twice (once for count, once when user picks "fix safe") because
|
|
475
|
-
// the second load happens after edits — keeps detection cheap on startup.
|
|
476
|
-
(async () => {
|
|
477
|
-
try {
|
|
478
|
-
const result = await checkGitignore(process.cwd());
|
|
479
|
-
if (result.violationCount > 0) {
|
|
480
|
-
const s = await loadGitignoreState(process.cwd());
|
|
481
|
-
const safeCount = s.violations.filter(
|
|
482
|
-
(v) => v.severity === "safe",
|
|
483
|
-
).length;
|
|
484
|
-
setGitignoreData({
|
|
485
|
-
violationCount: result.violationCount,
|
|
486
|
-
safeCount,
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
} catch {
|
|
490
|
-
// non-fatal
|
|
491
|
-
}
|
|
492
|
-
})();
|
|
493
|
-
|
|
494
500
|
repairAllMarketplaces()
|
|
495
501
|
.then(async () => {
|
|
496
502
|
dispatch({ type: "HIDE_PROGRESS" });
|
|
@@ -43,7 +43,7 @@ export interface PluginPluginItem {
|
|
|
43
43
|
/**
|
|
44
44
|
* The classifier's answer for this plugin — the same item `magus update`
|
|
45
45
|
* plans with. Null for a catalog entry the user is only browsing (neither
|
|
46
|
-
* declared by the profile
|
|
46
|
+
* declared by the profile nor installed): it has no state to claim.
|
|
47
47
|
*/
|
|
48
48
|
status: PluginUpdateItem | null;
|
|
49
49
|
}
|
|
@@ -8,23 +8,28 @@ interface ScopeSquaresProps {
|
|
|
8
8
|
selected?: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/** On at that scope. */
|
|
12
12
|
export const SCOPE_ON = "■";
|
|
13
|
-
/** Not
|
|
13
|
+
/** Not on at that scope. */
|
|
14
14
|
export const SCOPE_OFF = "□";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Scope bar: three positional squares, user / project / local.
|
|
18
18
|
*
|
|
19
19
|
* POSITION names the scope — the first square is always user, the second
|
|
20
|
-
* project, the third local — and SHAPE says the state: `■`
|
|
21
|
-
*
|
|
20
|
+
* project, the third local — and SHAPE says the state: `■` on, `□` not. "On"
|
|
21
|
+
* is the caller's to decide, and for a plugin and a skill alike it is
|
|
22
|
+
* installed. For a plugin that is `isInstalledInScope` — the scope's settings
|
|
23
|
+
* file turns it on, which is what Claude Code loads by — the same function the
|
|
24
|
+
* detail panel's dot reads, so the two cannot disagree.
|
|
25
|
+
*
|
|
26
|
+
* The shape reads with no colour at all, which the bar never used to: every square
|
|
22
27
|
* was the same filled block, lit in a scope hue or unlit in a fill near the
|
|
23
28
|
* page, so on a monochrome terminal, to a colour-blind reader, or on a theme
|
|
24
29
|
* that drew two hues alike, all three states were one shape.
|
|
25
30
|
*
|
|
26
31
|
* Colour now only repeats what the shape says. A filled square wears `success`
|
|
27
|
-
* (
|
|
32
|
+
* (on is what it means, on every screen); a hollow one wears `muted`,
|
|
28
33
|
* which a thin outline needs to stay visible — the shape, not a pale fill, is
|
|
29
34
|
* what makes it recede. On a selected row the selection owns the colour
|
|
30
35
|
* channel: filled squares take its ink, hollow ones its dim ink.
|