magus-cli 7.5.0 → 7.5.1

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 (33) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/profile-absorb.test.ts +72 -0
  3. package/src/__tests__/profile-dependencies.test.ts +442 -0
  4. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  5. package/src/__tests__/profile-skills.test.ts +99 -0
  6. package/src/__tests__/profile-state.test.ts +328 -13
  7. package/src/__tests__/stale-load-repair.test.ts +105 -0
  8. package/src/__tests__/support/preload.ts +14 -0
  9. package/src/__tests__/update-apply.test.ts +145 -0
  10. package/src/cli/bootstrap.ts +4 -0
  11. package/src/cli/doctor.ts +39 -6
  12. package/src/cli/install.ts +68 -4
  13. package/src/cli/router.ts +7 -0
  14. package/src/cli/update.ts +3 -0
  15. package/src/services/claude-cli.ts +24 -0
  16. package/src/services/claude-settings.ts +27 -0
  17. package/src/services/git-worktree.ts +92 -9
  18. package/src/services/manifest.ts +220 -3
  19. package/src/services/plugin-dependencies.ts +48 -0
  20. package/src/services/plugin-requires.ts +137 -14
  21. package/src/services/profile-absorb.ts +69 -15
  22. package/src/services/profile-activate.ts +179 -33
  23. package/src/services/profile-live.ts +66 -7
  24. package/src/services/profile-render.ts +73 -6
  25. package/src/services/profile-skills.ts +32 -7
  26. package/src/services/profile-state.ts +89 -5
  27. package/src/services/profiles.ts +6 -5
  28. package/src/services/resolver.ts +96 -3
  29. package/src/services/tracked-live-files.ts +125 -0
  30. package/src/services/update-engine.ts +87 -2
  31. package/src/types/index.ts +19 -0
  32. package/src/ui/screens/PluginsScreen.tsx +11 -2
  33. package/src/ui/screens/ProfilesScreen.tsx +10 -3
@@ -5,7 +5,8 @@
5
5
  * marketplaces, plugins, MCP servers, binary deps, skills, settings, and env a
6
6
  * machine must have to match the profile. Binary deps are the union of the
7
7
  * profile's own `cliTools` and every enabled plugin's declared `requires.bin`,
8
- * deduped by executable name with the project's pin winning.
8
+ * deduped by executable name with the project's pin winning. Plugins include
9
+ * every plugin an enabled one declares as a dependency, transitively.
9
10
  */
10
11
 
11
12
  import semver from "semver";
@@ -14,12 +15,17 @@ import { getMarketplaceByName } from "../data/marketplaces.js";
14
15
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
15
16
  import type {
16
17
  BinRequirement,
18
+ DependencyReport,
17
19
  ProfileManifest,
18
20
  ProfileManifestEntry,
19
21
  ResolvedBin,
20
22
  ResolvedClosure,
21
23
  } from "../types/index.js";
22
24
  import { readManifest } from "./manifest.js";
25
+ import {
26
+ type PluginDependencyReader,
27
+ readPluginDependencies,
28
+ } from "./plugin-dependencies.js";
23
29
  import { resolvePluginBinRequirements } from "./plugin-requires.js";
24
30
  import { activeProfile } from "./profile-live.js";
25
31
 
@@ -29,6 +35,61 @@ export interface ResolveOptions {
29
35
  * tests; defaults to reading plugin.json from the marketplace cache.
30
36
  */
31
37
  binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
38
+ /**
39
+ * Resolve a plugin id to the plugin ids its plugin.json declares as
40
+ * dependencies, or null when it cannot be read. Defaults to reading the
41
+ * plugin.json on this machine (`readPluginDependencies`).
42
+ */
43
+ dependencyResolver?: PluginDependencyReader;
44
+ }
45
+
46
+ /**
47
+ * Every plugin the `enabled` plugins depend on, transitively, as their own
48
+ * plugin.json files declare it.
49
+ *
50
+ * Claude Code refuses to load a plugin whose dependency is not enabled, so a
51
+ * dependency is part of what a profile enables — derived, never written into
52
+ * profiles.json. A dependency the profile itself turns off (`off`) is left
53
+ * off and reported as `blocked`; nothing it depends on is followed. A plugin
54
+ * whose plugin.json cannot be read is reported as `unreadable` rather than
55
+ * guessed at.
56
+ */
57
+ export async function resolveDependencies(
58
+ enabled: readonly string[],
59
+ off: ReadonlySet<string>,
60
+ read: PluginDependencyReader,
61
+ ): Promise<DependencyReport> {
62
+ const derived: string[] = [];
63
+ const unreadable: string[] = [];
64
+ const blocked: DependencyReport["blocked"] = [];
65
+ const queue = [...enabled];
66
+ const seen = new Set(enabled);
67
+ for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
68
+ const dependencies = await read(id);
69
+ if (dependencies === null) {
70
+ unreadable.push(id);
71
+ continue;
72
+ }
73
+ for (const dependency of dependencies) {
74
+ if (off.has(dependency)) {
75
+ blocked.push({ plugin: id, dependency });
76
+ continue;
77
+ }
78
+ if (seen.has(dependency)) continue;
79
+ seen.add(dependency);
80
+ derived.push(dependency);
81
+ queue.push(dependency);
82
+ }
83
+ }
84
+ return { derived: derived.sort(), unreadable: unreadable.sort(), blocked };
85
+ }
86
+
87
+ function hasDependencies(report: DependencyReport): boolean {
88
+ return (
89
+ report.derived.length > 0 ||
90
+ report.unreadable.length > 0 ||
91
+ report.blocked.length > 0
92
+ );
32
93
  }
33
94
 
34
95
  /** Normalize a manifest plugins map to pluginId -> version ("latest" for true). */
@@ -234,16 +295,25 @@ export async function resolveProfile(
234
295
  const binResolver = opts.binResolver ?? resolvePluginBinRequirements;
235
296
 
236
297
  const plugins = normalizePlugins(entry.plugins);
237
- const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
238
-
239
298
  const disabledPlugins = Object.entries(entry.plugins ?? {})
240
299
  .filter(([, ver]) => ver === false)
241
300
  .map(([id]) => id);
242
301
 
302
+ // The plugins the enabled ones need in order to load at all.
303
+ const dependencies = await resolveDependencies(
304
+ Object.keys(plugins),
305
+ new Set(disabledPlugins),
306
+ opts.dependencyResolver ?? readPluginDependencies,
307
+ );
308
+ for (const id of dependencies.derived) plugins[id] = "latest";
309
+
310
+ const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
311
+
243
312
  return {
244
313
  marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
245
314
  plugins,
246
315
  ...(disabledPlugins.length > 0 ? { disabledPlugins } : {}),
316
+ ...(hasDependencies(dependencies) ? { dependencies } : {}),
247
317
  mcpServers: { ...(entry.mcpServers ?? {}) },
248
318
  bins,
249
319
  skills: [...(entry.skills ?? [])],
@@ -333,6 +403,9 @@ function mergeClosures(
333
403
  }
334
404
  }
335
405
 
406
+ // Dependency reports: union, for `install` and `doctor` to report.
407
+ const dependencies = mergeDependencyReports(a.dependencies, b.dependencies);
408
+
336
409
  // Skills: union by repo+path.
337
410
  const skillKey = (s: { repo: string; path: string }) => `${s.repo}#${s.path}`;
338
411
  const skillMap = new Map(b.skills.map((s) => [skillKey(s), s]));
@@ -353,6 +426,26 @@ function mergeClosures(
353
426
  optional: [...new Set([...a.env.optional, ...b.env.optional])],
354
427
  },
355
428
  ...(conflicts.length > 0 ? { conflicts: [...new Set(conflicts)] } : {}),
429
+ ...(dependencies ? { dependencies } : {}),
430
+ };
431
+ }
432
+
433
+ function mergeDependencyReports(
434
+ a: DependencyReport | undefined,
435
+ b: DependencyReport | undefined,
436
+ ): DependencyReport | undefined {
437
+ if (!a) return b;
438
+ if (!b) return a;
439
+ const blocked = new Map(
440
+ [...a.blocked, ...b.blocked].map((x) => [
441
+ `${x.plugin}\u0000${x.dependency}`,
442
+ x,
443
+ ]),
444
+ );
445
+ return {
446
+ derived: [...new Set([...a.derived, ...b.derived])].sort(),
447
+ unreadable: [...new Set([...a.unreadable, ...b.unreadable])].sort(),
448
+ blocked: [...blocked.values()],
356
449
  };
357
450
  }
358
451
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * magus never writes a generated file that git tracks.
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.
12
+ *
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`.
17
+ */
18
+
19
+ import path from "node:path";
20
+ import { GitQueryError, trackedByGit } from "./git-worktree.js";
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
+ }
45
+
46
+ /**
47
+ * The project is a git work tree, but git could not say what it tracks — it
48
+ * 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.
51
+ */
52
+ export class GitCheckError extends LiveFileRefusal {
53
+ constructor(readonly detail: string) {
54
+ 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"),
63
+ );
64
+ this.name = "GitCheckError";
65
+ }
66
+ }
67
+
68
+ /** How to untrack one generated path — `-r` for a skill folder. */
69
+ 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}`;
74
+ }
75
+
76
+ /**
77
+ * Which of `files` (exact entries) and `dirs` (any file under them) git
78
+ * tracks, relative to `projectPath`. Empty outside a git work tree; throws
79
+ * {@link GitCheckError} when inside one and git cannot answer.
80
+ */
81
+ export async function trackedPaths(
82
+ projectPath: string,
83
+ files: readonly string[],
84
+ dirs: readonly string[] = [],
85
+ ): Promise<Set<string>> {
86
+ try {
87
+ return await trackedByGit(projectPath, files, dirs);
88
+ } catch (error) {
89
+ if (error instanceof GitQueryError) throw new GitCheckError(error.detail);
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ /**
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.
98
+ */
99
+ async function hasProfiles(projectPath: string): Promise<boolean> {
100
+ try {
101
+ const file = await readProfilesFile(getManifestPath(projectPath));
102
+ return Object.keys(file.profiles).length > 0;
103
+ } catch {
104
+ return true;
105
+ }
106
+ }
107
+
108
+ /**
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.
113
+ */
114
+ export async function refuseTrackedLiveFiles(
115
+ 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);
125
+ }
@@ -42,9 +42,11 @@ import type { PluginScope } from "./claude-cli.js";
42
42
  import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
43
43
  import {
44
44
  type StaleLoadRepairResult,
45
+ loadedVersionHere,
45
46
  repairStaleLoad,
46
47
  } from "./claude-settings.js";
47
48
  import {
49
+ isRepairablyBehind,
48
50
  readInstalledVersionForScope,
49
51
  saveInstalledPluginVersionForScope,
50
52
  } from "./plugin-manager.js";
@@ -181,6 +183,15 @@ export interface PluginApplyDeps {
181
183
  target: string,
182
184
  projectPath: string,
183
185
  ) => Promise<StaleLoadRepairResult>;
186
+ /**
187
+ * The version Claude Code's loader resolves for a plugin in a project — the
188
+ * question an install must ask afterwards (see {@link bringLoadForward}).
189
+ * Optional so a test double that does not model the loader skips the check.
190
+ */
191
+ loadedVersion?: (
192
+ pluginId: string,
193
+ projectPath: string,
194
+ ) => Promise<string | null>;
184
195
  }
185
196
 
186
197
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
@@ -190,8 +201,47 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
190
201
  readInstalled: readInstalledVersionForScope,
191
202
  saveInstalled: saveInstalledPluginVersionForScope,
192
203
  repairStaleLoad,
204
+ loadedVersion: loadedVersionHere,
193
205
  };
194
206
 
207
+ /**
208
+ * After a plugin landed at `target` for `projectPath`: make sure that is the
209
+ * version Claude Code actually loads there, and bring the loader forward when
210
+ * it is not.
211
+ *
212
+ * The loader takes the FIRST registry row relevant to the repository, and rows
213
+ * from this repository's other checkouts — deleted worktrees included — often
214
+ * precede this project's own row. `claude plugin install|update` writes only
215
+ * this project's row, so a successful update could leave the plugin showing
216
+ * "updated" and loading the old version at once (measured: an update wrote
217
+ * 2.0.5 to this project's row while eight earlier rows held 2.0.4, and
218
+ * `--debug-file` showed 2.0.4 loading). So the update runs the stale-load
219
+ * repair as its own last step — the same `repairStaleLoad` the `stale-load`
220
+ * state uses, moving this repository's older rows forward to `target`.
221
+ *
222
+ * Null when nothing needed doing (the loader already resolves `target`, or the
223
+ * dep that asks it is absent); otherwise the repair's result, which the caller
224
+ * reports — `ok: false` means the plugin still does not load `target`.
225
+ */
226
+ export async function bringLoadForward(
227
+ pluginId: string,
228
+ target: string,
229
+ projectPath: string,
230
+ deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
231
+ ): Promise<StaleLoadRepairResult | null> {
232
+ if (!deps.loadedVersion) return null;
233
+ const loaded = await deps.loadedVersion(pluginId, projectPath);
234
+ if (!isRepairablyBehind(target, loaded)) return null;
235
+ return deps.repairStaleLoad(pluginId, target, projectPath);
236
+ }
237
+
238
+ /** The words for a load brought forward, appended to an item's detail. */
239
+ function describeLoadForward(outcome: StaleLoadRepairResult): string {
240
+ return `Claude Code now loads v${outcome.target} here (${outcome.rewritten} older record${
241
+ outcome.rewritten === 1 ? "" : "s"
242
+ } moved forward)`;
243
+ }
244
+
195
245
  // -- selection ---------------------------------------------------------------
196
246
 
197
247
  /**
@@ -448,7 +498,28 @@ export async function applyPlugins(
448
498
  }
449
499
  }
450
500
  const noMove = pluginNoMove(item, landed);
451
- if (noMove) {
501
+ // What landed here — the project row when there is one, since that is
502
+ // the row the loader must end up agreeing with.
503
+ const here =
504
+ landed.find(([scope]) => scope === "project")?.[1] ??
505
+ landed.find(([, actual]) => actual)?.[1] ??
506
+ null;
507
+ const forward = here
508
+ ? await bringLoadForward(item.pluginId, here, projectPath, deps)
509
+ : null;
510
+ if (forward && !forward.ok) {
511
+ // Installed, but not what loads: that is not "updated". Reported as
512
+ // a failure with the repair's own reason, so nothing says current.
513
+ report.finish(
514
+ false,
515
+ item.pluginId,
516
+ `installed v${here}, but Claude Code still loads v${
517
+ forward.loaded ?? "an older version"
518
+ } here — ${forward.error ?? "the older records could not be moved forward"}`,
519
+ Date.now() - startedAt,
520
+ );
521
+ result.failed.push(item.pluginId);
522
+ } else if (noMove && !forward) {
452
523
  report.finish(
453
524
  true,
454
525
  item.pluginId,
@@ -457,10 +528,11 @@ export async function applyPlugins(
457
528
  );
458
529
  result.unchanged.push(item.pluginId);
459
530
  } else {
531
+ const moved = `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`;
460
532
  report.finish(
461
533
  true,
462
534
  item.pluginId,
463
- `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
535
+ forward ? `${moved} · ${describeLoadForward(forward)}` : moved,
464
536
  Date.now() - startedAt,
465
537
  );
466
538
  result.ok++;
@@ -499,6 +571,19 @@ async function applyOneInScope(
499
571
  await run(pluginId, scope);
500
572
  const actual = await deps.readInstalled(pluginId, scope, projectPath);
501
573
  if (actual) await deps.saveInstalled(pluginId, actual, scope, projectPath);
574
+ // The same last step as the apply loop, at every scope: installed must mean
575
+ // loaded here. `repairStaleLoad` itself refuses — and says why — when the
576
+ // winning record is not one this repository's rows can move.
577
+ if (actual) {
578
+ const forward = await bringLoadForward(pluginId, actual, projectPath, deps);
579
+ if (forward && !forward.ok) {
580
+ throw new Error(
581
+ `installed v${actual}, but Claude Code still loads v${
582
+ forward.loaded ?? "an older version"
583
+ } here — ${forward.error ?? "the older records could not be moved forward"}`,
584
+ );
585
+ }
586
+ }
502
587
  return actual;
503
588
  }
504
589
 
@@ -471,4 +471,23 @@ export interface ResolvedClosure {
471
471
  env: { required: string[]; optional: string[] };
472
472
  /** Cross-profile version conflicts detected while unioning profiles. */
473
473
  conflicts?: string[];
474
+ /**
475
+ * What the enabled plugins' own plugin.json files require — present when
476
+ * anything was derived or could not be read. The derived ids are already
477
+ * in `plugins`.
478
+ */
479
+ dependencies?: DependencyReport;
480
+ }
481
+
482
+ /**
483
+ * The dependencies a profile's enabled plugins declare, resolved transitively.
484
+ * Derived, never recorded: profiles.json names none of them.
485
+ */
486
+ export interface DependencyReport {
487
+ /** Enabled only because an enabled plugin depends on them. Sorted. */
488
+ derived: string[];
489
+ /** Enabled plugins whose plugin.json could not be read here. Sorted. */
490
+ unreadable: string[];
491
+ /** A dependency the profile itself turns off: `plugin` will not load. */
492
+ blocked: Array<{ plugin: string; dependency: string }>;
474
493
  }
@@ -44,6 +44,7 @@ import {
44
44
  fetchPluginContents,
45
45
  fetchPluginSkillDetail,
46
46
  } from "../../services/skills-manager.js";
47
+ import { LiveFileRefusal } from "../../services/tracked-live-files.js";
47
48
  // The one update implementation, shared with `magus update`. See the header
48
49
  // of update-engine.ts for what drifted while the TUI had its own loop.
49
50
  import {
@@ -982,7 +983,11 @@ export function PluginsScreen() {
982
983
  fetchData();
983
984
  } catch (error) {
984
985
  modal.hideModal();
985
- await modal.message("Error", `Failed: ${error}`, "error");
986
+ await modal.message(
987
+ "Error",
988
+ error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
989
+ "error",
990
+ );
986
991
  }
987
992
  }
988
993
  };
@@ -1202,7 +1207,11 @@ export function PluginsScreen() {
1202
1207
  fetchData();
1203
1208
  } catch (error) {
1204
1209
  modal.hideModal();
1205
- await modal.message("Error", `Failed: ${error}`, "error");
1210
+ await modal.message(
1211
+ "Error",
1212
+ error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
1213
+ "error",
1214
+ );
1206
1215
  }
1207
1216
  };
1208
1217
 
@@ -26,6 +26,7 @@ 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";
29
30
  import {
30
31
  ClipboardUnavailableError,
31
32
  readClipboard,
@@ -177,7 +178,9 @@ export function ProfilesScreen() {
177
178
  modal.hideModal();
178
179
  await modal.message(
179
180
  "Error",
180
- `Failed to apply profile: ${error}`,
181
+ error instanceof LiveFileRefusal
182
+ ? error.message
183
+ : `Failed to apply profile: ${error}`,
181
184
  "error",
182
185
  );
183
186
  }
@@ -242,7 +245,9 @@ export function ProfilesScreen() {
242
245
  modal.hideModal();
243
246
  await modal.message(
244
247
  "Error",
245
- `Failed to switch profile: ${error}`,
248
+ error instanceof LiveFileRefusal
249
+ ? error.message
250
+ : `Failed to switch profile: ${error}`,
246
251
  "error",
247
252
  );
248
253
  }
@@ -275,7 +280,9 @@ export function ProfilesScreen() {
275
280
  modal.hideModal();
276
281
  await modal.message(
277
282
  "Error",
278
- `Failed to apply profile: ${error}`,
283
+ error instanceof LiveFileRefusal
284
+ ? error.message
285
+ : `Failed to apply profile: ${error}`,
279
286
  "error",
280
287
  );
281
288
  }