magus-cli 7.5.2 → 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.
@@ -41,10 +41,6 @@ import {
41
41
  type ProfileChoice,
42
42
  activateProfile,
43
43
  activationNotes,
44
- activeWritableSkillFolders,
45
- assertNoTrackedWriteTargets,
46
- writableSkillFolders,
47
- writeRefusal,
48
44
  } from "./profile-activate.js";
49
45
  import { adoptCurrentProject } from "./profile-adopt.js";
50
46
  import {
@@ -73,7 +69,6 @@ import {
73
69
  skillFolder,
74
70
  } from "./profile-skills.js";
75
71
  import type { SettingsParseError } from "./settings-file.js";
76
- import type { LiveFileRefusal } from "./tracked-live-files.js";
77
72
 
78
73
  export type ActiveProfileCheck =
79
74
  | { state: "no-profiles" }
@@ -304,11 +299,6 @@ export interface ProfileStatePrep {
304
299
  problems: string[];
305
300
  /** The files behind `problems` that could not be parsed. */
306
301
  unreadable: SettingsParseError[];
307
- /**
308
- * Set when a file magus would write is tracked by git, or git could not
309
- * say: nothing ran, and `problems` holds its message.
310
- */
311
- refusal?: LiveFileRefusal;
312
302
  }
313
303
 
314
304
  /**
@@ -322,27 +312,6 @@ export async function prepareProfileState(
322
312
  projectPath: string,
323
313
  opts: Pick<ActivateOptions, "installSkill"> = {},
324
314
  ): Promise<ProfileStatePrep> {
325
- // A project with profiles whose live files — or the active profile's
326
- // magus-installed skill folders — git tracks: every path below would write
327
- // one of them (or the marker, or the manifest on the way), so nothing runs,
328
- // not even the marker a unique match would restore.
329
- const manifest = await readManifest(projectPath);
330
- if (Object.keys(manifest.profiles).length > 0) {
331
- const refusal = await writeRefusal(
332
- projectPath,
333
- await activeWritableSkillFolders(projectPath, manifest),
334
- );
335
- if (refusal) {
336
- return {
337
- check: await checkActiveProfile(projectPath, { restore: false }),
338
- reconcile: null,
339
- lines: [],
340
- problems: [refusal.message],
341
- unreadable: [],
342
- refusal,
343
- };
344
- }
345
- }
346
315
  const check = await checkActiveProfile(projectPath);
347
316
  const out: ProfileStatePrep = {
348
317
  check,
@@ -524,22 +493,6 @@ export async function switchToProfile(
524
493
  );
525
494
  return result;
526
495
  }
527
- // Before the absorbs below write profiles.json: nothing at all is written
528
- // when a live file this switch would write is tracked by git.
529
- const marker = await readActiveMarker(projectPath);
530
- const refusal = await writeRefusal(projectPath, [
531
- ...(await writableSkillFolders(
532
- projectPath,
533
- target.skills ?? [],
534
- marker?.skills,
535
- )),
536
- ...(await activeWritableSkillFolders(projectPath, before)),
537
- ]);
538
- if (refusal) {
539
- result.problems.push(refusal.message);
540
- return result;
541
- }
542
-
543
496
  if (!take(await absorbBeforeConversion(projectPath, name))) return result;
544
497
  const installSkill = { installSkill: opts.installSkill };
545
498
  if (!take(await reconcileActiveProfile(projectPath, installSkill))) {
@@ -580,20 +533,6 @@ export async function applyProfileChoice(
580
533
  projectPath: string,
581
534
  opts: ActivateOptions = {},
582
535
  ): Promise<ActivationResult> {
583
- // Nothing is written — not the adopted manifest, not an absorbed edit —
584
- // when a live file this choice would write is tracked by git.
585
- const skills =
586
- choice.kind === "activate"
587
- ? (manifest.profiles[choice.id]?.skills ?? [])
588
- : [];
589
- await assertNoTrackedWriteTargets(
590
- projectPath,
591
- await writableSkillFolders(
592
- projectPath,
593
- skills,
594
- (await readActiveMarker(projectPath))?.skills,
595
- ),
596
- );
597
536
  if (choice.kind === "activate") {
598
537
  const absorbed: string[] = [];
599
538
  let changed = false;
@@ -635,7 +574,6 @@ export async function prepareProfileWrite(
635
574
  projectPath: string,
636
575
  ): Promise<ProfileStatePrep> {
637
576
  const prep = await prepareProfileState(projectPath);
638
- if (prep.refusal) throw prep.refusal;
639
577
  refuseOnProblems(prep);
640
578
  return prep;
641
579
  }
@@ -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
  }
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" });
@@ -12,19 +12,38 @@ import type { ModalState } from "../state/types.js";
12
12
  export interface BuildModalArgs {
13
13
  violationCount: number;
14
14
  safeCount: number;
15
+ /**
16
+ * Files magus generates from `.claude/profiles.json` that git tracks. The
17
+ * fix untracks these in place; every other destructive fix stays in the tab.
18
+ */
19
+ generated?: readonly string[];
15
20
  onFixSafe: () => void;
16
21
  onOpenTab: () => void;
17
22
  onDismiss: () => void;
18
23
  }
19
24
 
25
+ function entries(n: number): string {
26
+ return `${n} missing .gitignore entr${n === 1 ? "y" : "ies"}`;
27
+ }
28
+
29
+ function fixLabel(safeCount: number, generated: number): string {
30
+ if (generated > 0) {
31
+ const files = `${generated} generated file${generated === 1 ? "" : "s"}`;
32
+ return safeCount > 0
33
+ ? `Stop tracking ${files} and add ${entries(safeCount)}`
34
+ : `Stop tracking ${files}`;
35
+ }
36
+ return safeCount > 0
37
+ ? `Add ${entries(safeCount)}`
38
+ : "(no missing .gitignore entries)";
39
+ }
40
+
20
41
  export function buildGitignoreModal(args: BuildModalArgs): ModalState {
21
42
  const { violationCount, safeCount, onFixSafe, onOpenTab, onDismiss } = args;
43
+ const generated = args.generated ?? [];
22
44
  const options = [
23
45
  {
24
- label:
25
- safeCount > 0
26
- ? `Add ${safeCount} missing .gitignore entr${safeCount === 1 ? "y" : "ies"}`
27
- : "(no missing .gitignore entries)",
46
+ label: fixLabel(safeCount, generated.length),
28
47
  value: "fix-safe",
29
48
  },
30
49
  {
@@ -36,10 +55,20 @@ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
36
55
  value: "dismiss",
37
56
  },
38
57
  ];
58
+ const generatedNote =
59
+ generated.length > 0
60
+ ? `magus generates ${generated.length === 1 ? "this file" : "these files"} from .claude/profiles.json for each developer, so ${
61
+ generated.length === 1 ? "it is" : "they are"
62
+ } expected to be gitignored — but git tracks ${generated.length === 1 ? "it" : "them"}:\n${generated
63
+ .map((p) => ` ${p}`)
64
+ .join(
65
+ "\n",
66
+ )}\n\nThe first choice stops tracking ${generated.length === 1 ? "it" : "them"} (git rm --cached, staged for you to commit) and adds the .gitignore entries.\n\n`
67
+ : "";
39
68
  return {
40
69
  type: "select",
41
70
  title: "Gitignore violations detected",
42
- message: `Found ${violationCount} managed gitignore item(s) that do not match the current repo state.\n\nMissing .gitignore entries can be added now. Items that change git tracking are reviewed and fixed from the Gitignore tab.`,
71
+ message: `${generatedNote}Found ${violationCount} managed gitignore item(s) that do not match the current repo state.\n\nMissing .gitignore entries can be added now. Other items that change git tracking are reviewed and fixed from the Gitignore tab.`,
43
72
  options,
44
73
  onSelect: (value: string) => {
45
74
  if (value === "fix-safe") onFixSafe();
@@ -58,27 +87,44 @@ export function useGitignoreModal() {
58
87
  (args: {
59
88
  violationCount: number;
60
89
  safeCount: number;
61
- onFixSafe: () => Promise<void> | void;
90
+ generated?: readonly string[];
91
+ /** Returns what it did, shown before returning to the app. */
92
+ onFixSafe: () => Promise<string | undefined> | string | undefined;
62
93
  }) => {
63
94
  if (args.violationCount === 0) return;
64
95
  const dismiss = () => dispatch({ type: "HIDE_MODAL" });
65
96
  const modal = buildGitignoreModal({
66
97
  violationCount: args.violationCount,
67
98
  safeCount: args.safeCount,
99
+ generated: args.generated,
68
100
  onFixSafe: () => {
69
101
  dispatch({ type: "HIDE_MODAL" });
70
- void Promise.resolve(args.onFixSafe()).catch((err) => {
71
- dispatch({
72
- type: "SHOW_MODAL",
73
- modal: {
74
- type: "message",
75
- title: "Safe fixes failed",
76
- message: err instanceof Error ? err.message : String(err),
77
- variant: "error",
78
- onDismiss: dismiss,
79
- },
102
+ void Promise.resolve(args.onFixSafe())
103
+ .then((report) => {
104
+ if (!report) return;
105
+ dispatch({
106
+ type: "SHOW_MODAL",
107
+ modal: {
108
+ type: "message",
109
+ title: "Gitignore fixed",
110
+ message: report,
111
+ variant: "success",
112
+ onDismiss: dismiss,
113
+ },
114
+ });
115
+ })
116
+ .catch((err) => {
117
+ dispatch({
118
+ type: "SHOW_MODAL",
119
+ modal: {
120
+ type: "message",
121
+ title: "Safe fixes failed",
122
+ message: err instanceof Error ? err.message : String(err),
123
+ variant: "error",
124
+ onDismiss: dismiss,
125
+ },
126
+ });
80
127
  });
81
- });
82
128
  },
83
129
  onOpenTab: () => {
84
130
  dispatch({ type: "HIDE_MODAL" });
@@ -157,7 +157,11 @@ export function describeFailure(
157
157
  ].join("\n");
158
158
  }
159
159
 
160
- export function useProfileStartupModal(onQuit: () => void) {
160
+ export function useProfileStartupModal(
161
+ onQuit: () => void,
162
+ /** Called once a choice has activated a profile — see App's gitignore re-check. */
163
+ onActivated?: () => void,
164
+ ) {
161
165
  const { state, dispatch } = useApp();
162
166
  const { navigateToScreen } = useNavigation();
163
167
  const projectPath = state.projectPath;
@@ -191,6 +195,7 @@ export function useProfileStartupModal(onQuit: () => void) {
191
195
  onDisplace: (artifact) => moved.push(artifact),
192
196
  })
193
197
  .then((result: ActivationResult) => {
198
+ onActivated?.();
194
199
  const { profileId, displaced } = result;
195
200
  dispatch({
196
201
  type: "SHOW_MODAL",
@@ -270,7 +275,7 @@ export function useProfileStartupModal(onQuit: () => void) {
270
275
  }
271
276
  ask();
272
277
  },
273
- [dispatch, navigateToScreen, projectPath, onQuit],
278
+ [dispatch, navigateToScreen, projectPath, onQuit, onActivated],
274
279
  );
275
280
 
276
281
  return { show };
@@ -44,7 +44,6 @@ import {
44
44
  fetchPluginContents,
45
45
  fetchPluginSkillDetail,
46
46
  } from "../../services/skills-manager.js";
47
- import { LiveFileRefusal } from "../../services/tracked-live-files.js";
48
47
  // The one update implementation, shared with `magus update`. See the header
49
48
  // of update-engine.ts for what drifted while the TUI had its own loop.
50
49
  import {
@@ -983,11 +982,7 @@ export function PluginsScreen() {
983
982
  fetchData();
984
983
  } catch (error) {
985
984
  modal.hideModal();
986
- await modal.message(
987
- "Error",
988
- error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
989
- "error",
990
- );
985
+ await modal.message("Error", `Failed: ${error}`, "error");
991
986
  }
992
987
  }
993
988
  };
@@ -1207,11 +1202,7 @@ export function PluginsScreen() {
1207
1202
  fetchData();
1208
1203
  } catch (error) {
1209
1204
  modal.hideModal();
1210
- await modal.message(
1211
- "Error",
1212
- error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
1213
- "error",
1214
- );
1205
+ await modal.message("Error", `Failed: ${error}`, "error");
1215
1206
  }
1216
1207
  };
1217
1208
 
@@ -26,7 +26,6 @@ import {
26
26
  renameProfile,
27
27
  } from "../../services/profiles.js";
28
28
  import { writableMap } from "../../services/settings-file.js";
29
- import { LiveFileRefusal } from "../../services/tracked-live-files.js";
30
29
  import {
31
30
  ClipboardUnavailableError,
32
31
  readClipboard,
@@ -178,9 +177,7 @@ export function ProfilesScreen() {
178
177
  modal.hideModal();
179
178
  await modal.message(
180
179
  "Error",
181
- error instanceof LiveFileRefusal
182
- ? error.message
183
- : `Failed to apply profile: ${error}`,
180
+ `Failed to apply profile: ${error}`,
184
181
  "error",
185
182
  );
186
183
  }
@@ -245,9 +242,7 @@ export function ProfilesScreen() {
245
242
  modal.hideModal();
246
243
  await modal.message(
247
244
  "Error",
248
- error instanceof LiveFileRefusal
249
- ? error.message
250
- : `Failed to switch profile: ${error}`,
245
+ `Failed to switch profile: ${error}`,
251
246
  "error",
252
247
  );
253
248
  }
@@ -280,9 +275,7 @@ export function ProfilesScreen() {
280
275
  modal.hideModal();
281
276
  await modal.message(
282
277
  "Error",
283
- error instanceof LiveFileRefusal
284
- ? error.message
285
- : `Failed to apply profile: ${error}`,
278
+ `Failed to apply profile: ${error}`,
286
279
  "error",
287
280
  );
288
281
  }