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.
Files changed (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/gitignore-check.test.ts +11 -29
  12. package/src/__tests__/gitignore-profile.test.ts +248 -0
  13. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  14. package/src/__tests__/mate-availability.test.ts +11 -13
  15. package/src/__tests__/models-cli.test.ts +11 -0
  16. package/src/__tests__/plugin-contents.test.ts +0 -1
  17. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  18. package/src/__tests__/plugin-row-text.test.ts +18 -21
  19. package/src/__tests__/profile-command.test.ts +2 -2
  20. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  21. package/src/__tests__/profile-skills.test.ts +17 -22
  22. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  23. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  24. package/src/__tests__/profile-state.test.ts +152 -164
  25. package/src/__tests__/scope-action.test.ts +35 -38
  26. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  27. package/src/__tests__/select-restore.test.ts +209 -0
  28. package/src/__tests__/stale-load-repair.test.ts +1 -2
  29. package/src/__tests__/update-apply.test.ts +18 -2
  30. package/src/__tests__/update-null-readback.test.ts +300 -0
  31. package/src/__tests__/update-plan-parity.test.ts +249 -0
  32. package/src/__tests__/update-plan.test.ts +66 -67
  33. package/src/__tests__/version-unknown-state.test.ts +38 -39
  34. package/src/cli/bootstrap.ts +239 -70
  35. package/src/cli/doctor.ts +7 -20
  36. package/src/cli/install.ts +83 -27
  37. package/src/cli/prompt.ts +7 -3
  38. package/src/cli/router.ts +6 -4
  39. package/src/cli/select.ts +168 -0
  40. package/src/cli/update-view.ts +118 -6
  41. package/src/cli/update.ts +10 -1
  42. package/src/services/claude-cli.ts +0 -24
  43. package/src/services/claude-settings.ts +28 -7
  44. package/src/services/gitignore-check.ts +37 -49
  45. package/src/services/gitignore-resolver.ts +10 -1
  46. package/src/services/gitignore-service.ts +67 -0
  47. package/src/services/manifest.ts +31 -0
  48. package/src/services/mate-availability.ts +23 -29
  49. package/src/services/plugin-manager.ts +247 -105
  50. package/src/services/profile-absorb.ts +41 -24
  51. package/src/services/profile-activate.ts +102 -127
  52. package/src/services/profile-startup.ts +160 -4
  53. package/src/services/profile-state.ts +7 -68
  54. package/src/services/tracked-live-files.ts +109 -74
  55. package/src/services/update-engine.ts +28 -10
  56. package/src/services/update-plan.ts +78 -94
  57. package/src/ui/App.tsx +41 -35
  58. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  59. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  60. package/src/ui/hooks/useGitignoreModal.ts +63 -17
  61. package/src/ui/hooks/useProfileStartupModal.ts +94 -9
  62. package/src/ui/keymap.ts +1 -1
  63. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  64. package/src/ui/renderers/pluginRowText.ts +6 -2
  65. package/src/ui/renderers/pluginStatusText.ts +4 -3
  66. package/src/ui/screens/PluginsScreen.tsx +30 -52
  67. package/src/ui/screens/ProfilesScreen.tsx +3 -10
  68. 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 goes in at PROJECT
391
- * scope, matching `install`: a profile is a property of one repo, so its
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" ? ["project"] : item.scopes;
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` matches the project path EXACTLY (no worktree inheritance),
455
- * which is what makes it the right question to ask here.
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: every touched scope reads back exactly what it held before;
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 same = landed.every(
501
- ([scope, actual]) => (actual ?? undefined) === item.beforeVersions[scope],
517
+ const moved = landed.some(
518
+ ([scope, actual]) =>
519
+ actual !== null && actual !== item.beforeVersions[scope],
502
520
  );
503
- if (!same) return null;
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 nowhere. */
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: enabled in a settings file, but no registry row in any scope. */
87
- | "enabled-not-installed"
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
- /** Scopes holding an install this action must touch. Empty for a fresh install. */
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 highestInstalled}: nothing caps it, and
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 highestInstalled(info);
200
- const permitted = scopeVersions(info)
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 scopeVersions(info)
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
- * installed or enabled, but published by no marketplace we read — into the same
259
- * list with `isOrphaned: true`, so a plain `has(id)` is true for a plugin that
260
- * nothing publishes. Treating an orphan as listed is what let an orphan plan as
261
- * `current`: its `version` is filled in from its own installed version, so the
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 = highestInstalled(info);
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(scopeVersions(info)) as Partial<
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
- // Not installed anywhere — the "checked but not installed" case.
358
+ // No installed scope has a version — the "checked but not installed" case.
392
359
  //
393
- // Nothing is installed in any scope here, so "enabled somewhere" is exactly
394
- // "enabled but not installed". It is tested directly rather than through
395
- // `isEnabledButNotInstalled`, which excludes orphans: an orphan that is only
396
- // a settings key would otherwise read as `absent`, and the TUI renders
397
- // `absent` as "the profile declares it", which an undeclared orphan does not.
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 brokenEnable = enabledAnywhere(info);
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: brokenEnable ? "enabled-not-installed" : "absent",
405
- scopes: [],
406
- ...(brokenEnable
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: scopeVersions(info).map(([scope]) => scope),
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
- * Decide what to do with every plugin the profile declares.
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, keyed by plugin id
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: Map<string, PluginInfo>,
661
+ pinned: Readonly<Record<string, string>>,
662
+ catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
680
663
  ): PluginUpdateItem[] {
681
664
  const index = indexCatalog(catalog);
682
- return Object.entries(pinned)
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 or enabled
693
- * in one — what the TUI's own update actions have always planned with, so
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 neither installed
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 (scopeVersions(info).length > 0 || enabledAnywhere(info)) return "latest";
700
+ if (isInstalledAnywhere(info)) return "latest";
709
701
  return null;
710
702
  }
711
703
 
712
704
  /**
713
- * One status per row that has a pin. Rows whose {@link pinForRow} is null are
714
- * absent from the map.
715
- *
716
- * Computed once per data/pins change, and the same map feeds every consumer —
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
- const index = indexCatalog(plugins);
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
- applyAllSafeFixes,
13
- loadGitignoreState,
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] = useState<{
265
- violationCount: number;
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
- onFixSafe: async () => {
322
- const s = await loadGitignoreState(state.projectPath);
323
- await applyAllSafeFixes(state.projectPath, s.violations);
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(process.cwd());
409
- if (notices.length > 0) setProfileNotices(notices);
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, installed, nor enabled): it has no state to claim.
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
- /** Installed at that scope. */
11
+ /** On at that scope. */
12
12
  export const SCOPE_ON = "■";
13
- /** Not installed at that scope. */
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: `■` installed, `□` not.
21
- * That reads with no colour at all, which the bar never used to: every square
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
- * (installed is what it means, on every screen); a hollow one wears `muted`,
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.