magus-cli 7.5.1 → 7.5.3

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.
@@ -1,76 +1,54 @@
1
1
  /**
2
- * magus never writes a generated file that git tracks.
2
+ * Generated files that git tracks: reported, never refused.
3
3
  *
4
- * In a project with profiles, `.claude/settings.json`, `.mcp.json` and
5
- * `.claude/models.json` are generated per developer from `.claude/profiles.json`.
6
- * A committed copy is the team's hand-kept file: writing it leaves git
7
- * tracking a file that now differs per developer, so every write shows as a
8
- * modified tracked file and one developer's plugin set gets committed for
9
- * everyone. `.gitignore` cannot fix that — it does not untrack a file already
10
- * committed. So every writer refuses, having written nothing, and says how to
11
- * stop tracking it; the user decides.
4
+ * In a project with profiles, `.claude/settings.json`, `.mcp.json`,
5
+ * `.claude/models.json` and the magus-installed skill folders are generated
6
+ * per developer from `.claude/profiles.json`, and are expected to be
7
+ * gitignored. A repository that committed one before adopting profiles keeps
8
+ * tracking it — `.gitignore` does not untrack a committed file.
12
9
  *
13
- * Activation checks every path it would write up front (`profile-activate.ts`).
14
- * This module holds the refusal itself, and the check for the writers outside
15
- * activation: the TUI's direct settings edits, `.mcp.json` edits, and the
16
- * Claude CLI's project-scope installs, which write `.claude/settings.json`.
10
+ * magus writes those files anyway: every command that writes them proceeds
11
+ * exactly as for an untracked file. What changes is what it says. The CLI
12
+ * prints one warning per tracked file with the fix (`git rm --cached <path>`,
13
+ * then commit) and never runs git itself; `magus doctor` reports it as a note;
14
+ * the TUI asks on every open and, when told to, untracks them in place
15
+ * (`gitignore-check.ts`, `profileGitignoreManifest`).
17
16
  */
18
17
 
19
18
  import path from "node:path";
19
+ import type { GitignoreManifest } from "../types/gitignore.js";
20
20
  import { GitQueryError, trackedByGit } from "./git-worktree.js";
21
21
  import { getManifestPath, readProfilesFile } from "./manifest.js";
22
-
23
- /** magus refused to write a live file, and changed nothing. */
24
- export class LiveFileRefusal extends Error {}
25
-
26
- /** A live file magus would write is tracked by git. */
27
- export class TrackedLiveFileError extends LiveFileRefusal {
28
- constructor(readonly paths: readonly string[]) {
29
- const one = paths.length === 1;
30
- super(
31
- [
32
- `magus will not write ${one ? "a file" : "files"} git tracks in this project — ${
33
- one ? "it is" : "they are"
34
- } generated from .claude/profiles.json and ${one ? "differs" : "differ"} per developer:`,
35
- ...paths.map((p) => ` ${p}`),
36
- `Stop tracking ${one ? "it" : "them"}, commit, then run the command again:`,
37
- ...paths.map((p) => ` ${untrackCommand(p)}`),
38
- ' git commit -m "Stop tracking generated Claude Code config"',
39
- "Nothing was changed.",
40
- ].join("\n"),
41
- );
42
- this.name = "TrackedLiveFileError";
43
- }
44
- }
22
+ import {
23
+ LIVE_FILE_KEYS,
24
+ livePath,
25
+ readActiveMarker,
26
+ skillsRoot,
27
+ } from "./profile-live.js";
45
28
 
46
29
  /**
47
30
  * The project is a git work tree, but git could not say what it tracks — it
48
31
  * timed out, is missing, or refused the repository (`detected dubious
49
- * ownership`). Answering "nothing is tracked" would let magus write a tracked
50
- * file, so it refuses instead and passes on git's own words.
32
+ * ownership`). Reported with git's own words wherever the check is made; it
33
+ * never stops a write.
51
34
  */
52
- export class GitCheckError extends LiveFileRefusal {
35
+ export class GitCheckError extends Error {
53
36
  constructor(readonly detail: string) {
54
37
  super(
55
- [
56
- "magus could not check which files git tracks in this project, so it wrote nothing. git said:",
57
- ...detail
58
- .trim()
59
- .split("\n")
60
- .map((line) => ` ${line}`),
61
- "Fix that, then run the command again. Nothing was changed.",
62
- ].join("\n"),
38
+ `magus could not check which files git tracks in this project. git said: ${detail.trim()}`,
63
39
  );
64
40
  this.name = "GitCheckError";
65
41
  }
66
42
  }
67
43
 
68
- /** How to untrack one generated path — `-r` for a skill folder. */
44
+ /** How to untrack one generated path. */
69
45
  export function untrackCommand(rel: string): string {
70
- const isDir =
71
- rel.split(path.sep).length > 2 &&
72
- rel.startsWith(path.join(".claude", "skills") + path.sep);
73
- return isDir ? `git rm --cached -r ${rel}` : `git rm --cached ${rel}`;
46
+ return `git rm --cached ${rel}`;
47
+ }
48
+
49
+ /** The warning the CLI prints for one tracked generated path. */
50
+ export function trackedWarning(rel: string): string {
51
+ return `${rel} is generated from .claude/profiles.json but tracked by git — run \`${untrackCommand(rel)}\`, then commit`;
74
52
  }
75
53
 
76
54
  /**
@@ -92,34 +70,70 @@ export async function trackedPaths(
92
70
  }
93
71
 
94
72
  /**
95
- * Does magus generate this project's live files — does its profiles.json
96
- * define a profile? A profiles.json that cannot be read counts as yes: the
97
- * check that follows only ever stops a write.
73
+ * Of `folders` under `.claude/skills/`, the ones git tracks — the team's own
74
+ * copies. A skill folder git tracks is the team's, never magus's: magus does
75
+ * not install over it, manage it, flag it or untrack it. Empty when git cannot
76
+ * say, so a failed git call never turns a team skill into one magus removes —
77
+ * it only leaves a folder magus recorded as its own until git answers.
98
78
  */
99
- async function hasProfiles(projectPath: string): Promise<boolean> {
79
+ export async function trackedSkillFolders(
80
+ projectPath: string,
81
+ folders: readonly string[],
82
+ ): Promise<Set<string>> {
83
+ if (folders.length === 0) return new Set();
84
+ const root = path.relative(projectPath, skillsRoot(projectPath));
85
+ let tracked: Set<string>;
100
86
  try {
101
- const file = await readProfilesFile(getManifestPath(projectPath));
102
- return Object.keys(file.profiles).length > 0;
103
- } catch {
104
- return true;
87
+ tracked = await trackedPaths(
88
+ projectPath,
89
+ [],
90
+ folders.map((folder) => path.join(root, folder)),
91
+ );
92
+ } catch (error) {
93
+ if (error instanceof GitCheckError) return new Set();
94
+ throw error;
105
95
  }
96
+ return new Set(folders.filter((f) => tracked.has(path.join(root, f))));
106
97
  }
107
98
 
108
99
  /**
109
- * Throw {@link TrackedLiveFileError} — before anything is written — when
110
- * `projectPath` has profiles and git tracks any of `files` (absolute, or
111
- * relative to the project). A project without profiles owns its files by
112
- * hand, and writing them is not magus's to refuse.
100
+ * The gitignore rules the profile model implies, for the gitignore manager's
101
+ * `profile` tier — or null in a project without profiles.
102
+ *
103
+ * In a project with profiles — profiles.json defines at least one — the three
104
+ * live files and the magus-installed skill folders are generated per
105
+ * developer, so they must be ignored. The built-in defaults say to TRACK
106
+ * `.claude/settings.json` and `.mcp.json`, which is right for a project that
107
+ * writes them by hand; this tier overrides that for a profiled one, so a
108
+ * committed copy is reported as "tracked but should be ignored". A left-over
109
+ * marker in a repo that dropped profiles.json does not make it profiled.
110
+ *
111
+ * A skill folder git tracks is left out even when the marker records it:
112
+ * markers written by 7.5.0's conversion recorded committed skills as
113
+ * magus-installed, and the team's copy must never be flagged or untracked.
113
114
  */
114
- export async function refuseTrackedLiveFiles(
115
+ export async function profileGitignoreManifest(
115
116
  projectPath: string,
116
- files: readonly string[],
117
- ): Promise<void> {
118
- if (!(await hasProfiles(projectPath))) return;
119
- const rel = files.map((f) =>
120
- path.isAbsolute(f) ? path.relative(projectPath, f) : f,
121
- );
122
- const tracked = await trackedPaths(projectPath, rel);
123
- const hit = rel.filter((r) => tracked.has(r));
124
- if (hit.length > 0) throw new TrackedLiveFileError(hit);
117
+ ): Promise<GitignoreManifest | null> {
118
+ try {
119
+ const file = await readProfilesFile(getManifestPath(projectPath));
120
+ if (Object.keys(file.profiles).length === 0) return null;
121
+ } catch {
122
+ return null; // An unreadable profiles.json is reported by every profile command.
123
+ }
124
+ const marker = await readActiveMarker(projectPath);
125
+ const managed = Object.keys(marker?.skills ?? {});
126
+ const committed = await trackedSkillFolders(projectPath, managed);
127
+ const posix = (abs: string) =>
128
+ path.relative(projectPath, abs).split(path.sep).join("/");
129
+ const skills = posix(skillsRoot(projectPath));
130
+ return {
131
+ ignore: [
132
+ ...LIVE_FILE_KEYS.map((key) => posix(livePath(key, projectPath))),
133
+ ...managed
134
+ .filter((folder) => !committed.has(folder))
135
+ .map((folder) => `${skills}/${folder}/`),
136
+ ],
137
+ track: [],
138
+ };
125
139
  }
@@ -31,6 +31,7 @@
31
31
 
32
32
  import path from "node:path";
33
33
  import type {
34
+ InstalledPluginEntry,
34
35
  ProfileSkillRef,
35
36
  ResolvedBin,
36
37
  SkillInfo,
@@ -43,9 +44,12 @@ import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
43
44
  import {
44
45
  type StaleLoadRepairResult,
45
46
  loadedVersionHere,
47
+ readInstalledPluginsRegistry,
46
48
  repairStaleLoad,
49
+ rowBelongsToRepository,
47
50
  } from "./claude-settings.js";
48
51
  import {
52
+ compareVersions,
49
53
  isRepairablyBehind,
50
54
  readInstalledVersionForScope,
51
55
  saveInstalledPluginVersionForScope,
@@ -192,6 +196,21 @@ export interface PluginApplyDeps {
192
196
  pluginId: string,
193
197
  projectPath: string,
194
198
  ) => Promise<string | null>;
199
+ /**
200
+ * Every registry row for a plugin, read fresh — compared before and after
201
+ * `update` to see WHICH row Claude Code moved (see
202
+ * {@link followMisdirectedUpdate}). Optional so a test double that does not
203
+ * model the registry skips the comparison.
204
+ */
205
+ registryRows?: (pluginId: string) => Promise<readonly InstalledPluginEntry[]>;
206
+ /**
207
+ * Is a row this project's repository's — the rows the stale-load repair may
208
+ * rewrite. Defaults to that repair's own predicate (`rowBelongsToRepository`).
209
+ */
210
+ inThisRepository?: (
211
+ row: InstalledPluginEntry,
212
+ projectPath: string,
213
+ ) => boolean;
195
214
  }
196
215
 
197
216
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
@@ -202,8 +221,129 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
202
221
  saveInstalled: saveInstalledPluginVersionForScope,
203
222
  repairStaleLoad,
204
223
  loadedVersion: loadedVersionHere,
224
+ registryRows: async (pluginId) => {
225
+ const rows = (await readInstalledPluginsRegistry()).plugins?.[pluginId];
226
+ return Array.isArray(rows) ? rows : [];
227
+ },
205
228
  };
206
229
 
230
+ /**
231
+ * An update Claude Code applied to another project's row instead of this one.
232
+ *
233
+ * Claude Code 2.1.281 changed `claude plugin update P --scope project`: it
234
+ * updates the FIRST registry row relevant to the repository — the row its
235
+ * loader would pick — not the row of the project it runs in. Measured
236
+ * 2026-09-24 on two APFS clones of one sandbox, same registry, cwd = the
237
+ * project:
238
+ *
239
+ * 2.1.280 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src)
240
+ * 2.1.281 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src/tools/claudeup)
241
+ *
242
+ * Both exit 0; under 2.1.281 this project's row stays at 8.0.0.
243
+ */
244
+ export interface MisdirectedUpdate {
245
+ /** Where Claude Code wrote instead: each moved row's project path, or scope. */
246
+ elsewhere: string[];
247
+ /** The version it moved them to. */
248
+ version: string;
249
+ /** Why this project's row did not follow to `version`; null when it did. */
250
+ notFollowed: string | null;
251
+ }
252
+
253
+ /** One registry row's identity: scope, and the project it belongs to. */
254
+ function rowKey(
255
+ row: Pick<InstalledPluginEntry, "scope" | "projectPath">,
256
+ ): string {
257
+ const where = row.projectPath ? path.resolve(row.projectPath) : "";
258
+ return `${row.scope}\u0000${where}`;
259
+ }
260
+
261
+ async function readRows(
262
+ deps: PluginApplyDeps,
263
+ pluginId: string,
264
+ ): Promise<readonly InstalledPluginEntry[] | null> {
265
+ if (!deps.registryRows) return null;
266
+ try {
267
+ return await deps.registryRows(pluginId);
268
+ } catch {
269
+ return null; // No readable registry: nothing to compare.
270
+ }
271
+ }
272
+
273
+ /**
274
+ * After `claude plugin update P --scope <scope>`: if this project's row did not
275
+ * move but another row OF THIS REPOSITORY did — Claude Code 2.1.281 updating
276
+ * the repository's first relevant row instead (see {@link MisdirectedUpdate})
277
+ * — move this project's row forward to that version, with the registry
278
+ * rewrite the stale-load repair performs. That repair refuses a version whose
279
+ * cache directory does not exist.
280
+ *
281
+ * Only rows the repair itself would rewrite count (`inThisRepository`): a row
282
+ * of another repository that happens to move while the CLI runs says nothing
283
+ * about this update. And when the update's `target` is known, the row must
284
+ * have reached exactly that version, or it is not followed.
285
+ *
286
+ * The row Claude Code moved is left as it is: it belongs to this repository,
287
+ * and bringing this repository's rows forward is what the repair does anyway.
288
+ * Null when there was nothing to follow — this project's row moved (2.1.280),
289
+ * or no row of this repository moved at all.
290
+ */
291
+ async function followMisdirectedUpdate(
292
+ deps: PluginApplyDeps,
293
+ pluginId: string,
294
+ scope: PluginScope,
295
+ projectPath: string,
296
+ before: readonly InstalledPluginEntry[],
297
+ target: string | null,
298
+ ): Promise<MisdirectedUpdate | null> {
299
+ const now = await deps.readInstalled(pluginId, scope, projectPath);
300
+ const after = await readRows(deps, pluginId);
301
+ if (!now || !after) return null;
302
+ const inThisRepository = deps.inThisRepository ?? rowBelongsToRepository;
303
+ const was = new Map(before.map((row) => [rowKey(row), row.version]));
304
+ const moved = after.filter(
305
+ (row) =>
306
+ row.version &&
307
+ was.get(rowKey(row)) !== row.version &&
308
+ inThisRepository(row, projectPath),
309
+ );
310
+ const mine = rowKey({
311
+ scope,
312
+ projectPath: scope === "user" ? undefined : projectPath,
313
+ });
314
+ if (moved.length === 0 || moved.some((row) => rowKey(row) === mine)) {
315
+ return null;
316
+ }
317
+ const version = moved
318
+ .map((row) => row.version)
319
+ .reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
320
+ if (!isRepairablyBehind(version, now)) return null;
321
+ const elsewhere = moved.map((row) => row.projectPath ?? `${row.scope} scope`);
322
+ if (target && version !== target) {
323
+ return {
324
+ elsewhere,
325
+ version,
326
+ notFollowed: `it went to v${version}, not the v${target} this update asked for`,
327
+ };
328
+ }
329
+ const repair = await deps.repairStaleLoad(pluginId, version, projectPath);
330
+ return {
331
+ elsewhere,
332
+ version,
333
+ notFollowed: repair.ok
334
+ ? null
335
+ : (repair.error ?? "the registry was not rewritten"),
336
+ };
337
+ }
338
+
339
+ /** The words for a misdirected update, for an item's detail. */
340
+ function describeMisdirected(m: MisdirectedUpdate): string {
341
+ const where = m.elsewhere.join(", ");
342
+ return m.notFollowed === null
343
+ ? `Claude Code updated ${where} instead of this project — moved this project's record to v${m.version} too`
344
+ : `Claude Code updated ${where} instead of this project, and this project's record could not follow: ${m.notFollowed}`;
345
+ }
346
+
207
347
  /**
208
348
  * After a plugin landed at `target` for `projectPath`: make sure that is the
209
349
  * version Claude Code actually loads there, and bring the loader forward when
@@ -319,10 +459,26 @@ async function advanceInScope(
319
459
  pluginId: string,
320
460
  scope: PluginScope,
321
461
  projectPath: string,
322
- ): Promise<void> {
462
+ /** The version this update targets, when known — see followMisdirectedUpdate. */
463
+ target: string | null = null,
464
+ ): Promise<MisdirectedUpdate | null> {
323
465
  const here = await deps.readInstalled(pluginId, scope, projectPath);
324
- if (here) await deps.update(pluginId, scope);
325
- else await deps.install(pluginId, scope);
466
+ if (!here) {
467
+ await deps.install(pluginId, scope);
468
+ return null;
469
+ }
470
+ const before = await readRows(deps, pluginId);
471
+ await deps.update(pluginId, scope);
472
+ return before
473
+ ? followMisdirectedUpdate(
474
+ deps,
475
+ pluginId,
476
+ scope,
477
+ projectPath,
478
+ before,
479
+ target,
480
+ )
481
+ : null;
326
482
  }
327
483
 
328
484
  /**
@@ -449,6 +605,7 @@ export async function applyPlugins(
449
605
  report.begin(item.pluginId);
450
606
  const startedAt = Date.now();
451
607
  const landed: Array<[PluginScope, string | null]> = [];
608
+ const misdirected: MisdirectedUpdate[] = [];
452
609
 
453
610
  try {
454
611
  for (const scope of scopes) {
@@ -467,7 +624,14 @@ export async function applyPlugins(
467
624
  } else if (item.action === "install") {
468
625
  await deps.install(item.pluginId, scope);
469
626
  } else {
470
- await advanceInScope(deps, item.pluginId, scope, projectPath);
627
+ const m = await advanceInScope(
628
+ deps,
629
+ item.pluginId,
630
+ scope,
631
+ projectPath,
632
+ item.target,
633
+ );
634
+ if (m) misdirected.push(m);
471
635
  }
472
636
 
473
637
  // Record what LANDED, not what we asked for.
@@ -520,19 +684,24 @@ export async function applyPlugins(
520
684
  );
521
685
  result.failed.push(item.pluginId);
522
686
  } else if (noMove && !forward) {
687
+ const why = misdirected.map(describeMisdirected);
523
688
  report.finish(
524
689
  true,
525
690
  item.pluginId,
526
- `no change — ${noMove}`,
691
+ `no change — ${noMove}${why.length > 0 ? ` (${why.join("; ")})` : ""}`,
527
692
  Date.now() - startedAt,
528
693
  );
529
694
  result.unchanged.push(item.pluginId);
530
695
  } else {
531
696
  const moved = `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`;
697
+ const notes = [
698
+ ...misdirected.map(describeMisdirected),
699
+ ...(forward ? [describeLoadForward(forward)] : []),
700
+ ];
532
701
  report.finish(
533
702
  true,
534
703
  item.pluginId,
535
- forward ? `${moved} · ${describeLoadForward(forward)}` : moved,
704
+ [moved, ...notes].join(" · "),
536
705
  Date.now() - startedAt,
537
706
  );
538
707
  result.ok++;
@@ -621,8 +790,12 @@ export async function updatePluginInScope(
621
790
  deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
622
791
  ): Promise<string | null> {
623
792
  // Same guard as the apply loop, for the same reason — see advanceInScope.
793
+ // A misdirected update is followed there; the version read back afterwards
794
+ // is what this returns.
624
795
  return applyOneInScope(
625
- (id, sc) => advanceInScope(deps, id, sc, projectPath),
796
+ async (id, sc) => {
797
+ await advanceInScope(deps, id, sc, projectPath);
798
+ },
626
799
  pluginId,
627
800
  scope,
628
801
  projectPath,
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
@@ -470,27 +492,6 @@ function AppContent({ onExit }: AppContentProps) {
470
492
  })
471
493
  .catch(() => {}); // non-fatal
472
494
 
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
495
  repairAllMarketplaces()
495
496
  .then(async () => {
496
497
  dispatch({ type: "HIDE_PROGRESS" });