magus-cli 7.4.1 → 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.
- package/README.md +9 -8
- package/package.json +5 -4
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1210 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +524 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +1110 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +105 -0
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +145 -0
- package/src/cli/bootstrap.ts +91 -27
- package/src/cli/doctor.ts +165 -38
- package/src/cli/install.ts +126 -122
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +39 -6
- package/src/cli/update.ts +46 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +27 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +113 -9
- package/src/services/manifest.ts +224 -8
- package/src/services/models-manager.ts +36 -73
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +1586 -0
- package/src/services/profile-activate.ts +492 -94
- package/src/services/profile-live.ts +884 -0
- package/src/services/profile-render.ts +182 -0
- package/src/services/profile-skills.ts +487 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +706 -0
- package/src/services/profiles.ts +26 -37
- package/src/services/resolver.ts +100 -2
- package/src/services/settings-file.ts +5 -5
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +87 -2
- package/src/types/index.ts +34 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +84 -29
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- package/src/services/symlink-manager.ts +0 -343
|
@@ -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
|
-
|
|
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
|
-
`${
|
|
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
|
|
package/src/types/index.ts
CHANGED
|
@@ -312,8 +312,8 @@ export interface ProfileEntry extends ProfileManifestEntry {
|
|
|
312
312
|
/**
|
|
313
313
|
* This profile is the one the project is actually running.
|
|
314
314
|
*
|
|
315
|
-
* Derived from the `.claude/
|
|
316
|
-
* `activeProfile`. Carried on the entry because a list of profiles with no
|
|
315
|
+
* Derived from the per-developer marker `.claude/profiles/active.json`, not
|
|
316
|
+
* stored in the manifest — see `activeProfile`. Carried on the entry because a list of profiles with no
|
|
317
317
|
* mark on the live one cannot answer the first question anybody asks of it:
|
|
318
318
|
* the Profiles tab showed two identical-looking rows straight after
|
|
319
319
|
* activating one of them.
|
|
@@ -377,8 +377,7 @@ export interface ProfileManifestEntry {
|
|
|
377
377
|
settings?: Record<string, unknown>;
|
|
378
378
|
/**
|
|
379
379
|
* Per-subagent model routing. A PROFILE property, so switching profiles
|
|
380
|
-
* switches routing
|
|
381
|
-
* `_profiles/<name>/models.json` for `.claude/models.json` to link at.
|
|
380
|
+
* switches routing; activation renders it to `.claude/models.json`.
|
|
382
381
|
*/
|
|
383
382
|
models?: ModelsConfig;
|
|
384
383
|
env?: ProfileEnvRequirements;
|
|
@@ -399,8 +398,9 @@ export interface ProfileManifest {
|
|
|
399
398
|
* use, so every teammate cloning the repo is asked to choose and has nothing
|
|
400
399
|
* to choose by — the repo knows the answer and had no way to write it down.
|
|
401
400
|
*
|
|
402
|
-
* It is intent, not state: the profile that is ACTUALLY active is
|
|
403
|
-
* (see `activeProfile`), which is
|
|
401
|
+
* It is intent, not state: the profile that is ACTUALLY active is recorded in
|
|
402
|
+
* the marker `.claude/profiles/active.json` (see `activeProfile`), which is
|
|
403
|
+
* per-developer and gitignored. This field is
|
|
404
404
|
* what a fresh clone activates so nobody is asked at all.
|
|
405
405
|
*
|
|
406
406
|
* Must name a profile that exists; `validateManifest` rejects it otherwise.
|
|
@@ -452,17 +452,42 @@ export interface ResolvedClosure {
|
|
|
452
452
|
marketplaces: Record<string, ProfileMarketplaceRef>;
|
|
453
453
|
/** pluginId -> version pin | "latest". */
|
|
454
454
|
plugins: Record<string, string>;
|
|
455
|
+
/**
|
|
456
|
+
* Plugin ids the profile declares as `false` — explicitly off in this
|
|
457
|
+
* project. Rendered as `false` in enabledPlugins so a plugin enabled at user
|
|
458
|
+
* scope stays off here. Set per profile; a union carries none.
|
|
459
|
+
*/
|
|
460
|
+
disabledPlugins?: string[];
|
|
455
461
|
mcpServers: Record<string, McpServerConfig>;
|
|
456
462
|
bins: ResolvedBin[];
|
|
457
463
|
skills: ProfileSkillRef[];
|
|
458
464
|
settings: Record<string, unknown>;
|
|
459
465
|
/**
|
|
460
|
-
* The profile's model routing, if it declares any. Absent means
|
|
461
|
-
*
|
|
462
|
-
*
|
|
466
|
+
* The profile's model routing, if it declares any. Absent means rendering
|
|
467
|
+
* REMOVES `.claude/models.json`, which is what makes `magus models off`
|
|
468
|
+
* take effect.
|
|
463
469
|
*/
|
|
464
470
|
models?: ModelsConfig;
|
|
465
471
|
env: { required: string[]; optional: string[] };
|
|
466
472
|
/** Cross-profile version conflicts detected while unioning profiles. */
|
|
467
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 }>;
|
|
468
493
|
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -16,8 +16,8 @@ import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
|
16
16
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
17
17
|
import {
|
|
18
18
|
type ProfileStartupNeed,
|
|
19
|
-
detectProfileStartupNeed,
|
|
20
19
|
needsNothing,
|
|
20
|
+
prepareProfileStartup,
|
|
21
21
|
} from "../services/profile-startup.js";
|
|
22
22
|
import {
|
|
23
23
|
type VersionCheckResult,
|
|
@@ -205,12 +205,15 @@ interface AppContentInnerProps {
|
|
|
205
205
|
updateInfo: VersionCheckResult | null;
|
|
206
206
|
onExit: () => void;
|
|
207
207
|
recoveryReport: string | null;
|
|
208
|
+
/** What the startup profile gate did: absorbed edits, regeneration, repairs. */
|
|
209
|
+
profileNotices: string[];
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
function AppContentInner({
|
|
211
213
|
updateInfo,
|
|
212
214
|
onExit,
|
|
213
215
|
recoveryReport,
|
|
216
|
+
profileNotices,
|
|
214
217
|
}: AppContentInnerProps) {
|
|
215
218
|
const { state } = useApp();
|
|
216
219
|
const { progress } = state;
|
|
@@ -224,6 +227,11 @@ function AppContentInner({
|
|
|
224
227
|
<text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
|
|
225
228
|
</box>
|
|
226
229
|
)}
|
|
230
|
+
{profileNotices.map((line) => (
|
|
231
|
+
<box key={line} paddingLeft={1} paddingRight={1}>
|
|
232
|
+
<text fg={theme.colors.success}>• {line}</text>
|
|
233
|
+
</box>
|
|
234
|
+
))}
|
|
227
235
|
{progress && <ProgressIndicator {...progress} />}
|
|
228
236
|
<box
|
|
229
237
|
flexDirection="column"
|
|
@@ -252,6 +260,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
252
260
|
const { progress } = state;
|
|
253
261
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
254
262
|
const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
|
|
263
|
+
const [profileNotices, setProfileNotices] = useState<string[]>([]);
|
|
255
264
|
const [gitignoreData, setGitignoreData] = useState<{
|
|
256
265
|
violationCount: number;
|
|
257
266
|
safeCount: number;
|
|
@@ -286,6 +295,14 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
286
295
|
return () => clearInterval(id);
|
|
287
296
|
}, []);
|
|
288
297
|
|
|
298
|
+
// Auto-dismiss the profile gate's notices after 10 seconds — long enough to
|
|
299
|
+
// read a line per absorbed change.
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (profileNotices.length === 0) return;
|
|
302
|
+
const timer = setTimeout(() => setProfileNotices([]), 10000);
|
|
303
|
+
return () => clearTimeout(timer);
|
|
304
|
+
}, [profileNotices]);
|
|
305
|
+
|
|
289
306
|
// Auto-dismiss recovery banner after 5 seconds
|
|
290
307
|
useEffect(() => {
|
|
291
308
|
if (!recoveryReport) return;
|
|
@@ -384,7 +401,12 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
384
401
|
useEffect(() => {
|
|
385
402
|
(async () => {
|
|
386
403
|
try {
|
|
387
|
-
|
|
404
|
+
// The same gate every CLI command runs — absorb Claude Code's
|
|
405
|
+
// edits into the active profile, regenerate from a changed
|
|
406
|
+
// profiles.json, restore a lost marker — then the question, if one
|
|
407
|
+
// is left to ask.
|
|
408
|
+
const { need, notices } = await prepareProfileStartup(process.cwd());
|
|
409
|
+
if (notices.length > 0) setProfileNotices(notices);
|
|
388
410
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
389
411
|
} catch {
|
|
390
412
|
// non-fatal: the Profiles tab still works by hand
|
|
@@ -492,6 +514,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
492
514
|
updateInfo={updateInfo}
|
|
493
515
|
onExit={onExit}
|
|
494
516
|
recoveryReport={recoveryReport}
|
|
517
|
+
profileNotices={profileNotices}
|
|
495
518
|
/>
|
|
496
519
|
</DimensionsProvider>
|
|
497
520
|
);
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
import { useCallback } from "react";
|
|
19
19
|
import { getManifestPath } from "../../services/manifest.js";
|
|
20
20
|
import {
|
|
21
|
+
type ActivationResult,
|
|
21
22
|
type ProfileChoice,
|
|
22
|
-
|
|
23
|
+
activationNotes,
|
|
23
24
|
} from "../../services/profile-activate.js";
|
|
25
|
+
import type { DisplacedArtifact } from "../../services/profile-live.js";
|
|
24
26
|
import {
|
|
25
27
|
type ProfileStartupNeed,
|
|
26
28
|
buildProfileOptions,
|
|
@@ -29,8 +31,8 @@ import {
|
|
|
29
31
|
parseProfileChoice,
|
|
30
32
|
unparseableNeed,
|
|
31
33
|
} from "../../services/profile-startup.js";
|
|
34
|
+
import { applyProfileChoice } from "../../services/profile-state.js";
|
|
32
35
|
import { SettingsParseError } from "../../services/settings-file.js";
|
|
33
|
-
import type { DisplacedArtifact } from "../../services/symlink-manager.js";
|
|
34
36
|
import { useApp, useNavigation } from "../state/AppContext.js";
|
|
35
37
|
import type { ModalState } from "../state/types.js";
|
|
36
38
|
|
|
@@ -80,6 +82,8 @@ export function describeOutcome(
|
|
|
80
82
|
projectPath: string,
|
|
81
83
|
/** True when this happened without asking, following the committed default. */
|
|
82
84
|
automatic = false,
|
|
85
|
+
/** Anything else activation reported (see `activationNotes`). */
|
|
86
|
+
notes: string[] = [],
|
|
83
87
|
): string {
|
|
84
88
|
const lines = [
|
|
85
89
|
automatic
|
|
@@ -89,7 +93,7 @@ export function describeOutcome(
|
|
|
89
93
|
if (displaced.length > 0) {
|
|
90
94
|
lines.push(
|
|
91
95
|
"",
|
|
92
|
-
"These were moved aside to make room for
|
|
96
|
+
"These were moved aside to make room for the profile's files, and are kept:",
|
|
93
97
|
...displaced.map((d) => {
|
|
94
98
|
const from = d.link.startsWith(projectPath)
|
|
95
99
|
? d.link.slice(projectPath.length + 1)
|
|
@@ -101,6 +105,7 @@ export function describeOutcome(
|
|
|
101
105
|
}),
|
|
102
106
|
);
|
|
103
107
|
}
|
|
108
|
+
if (notes.length > 0) lines.push("", ...notes);
|
|
104
109
|
return lines.join("\n");
|
|
105
110
|
}
|
|
106
111
|
|
|
@@ -112,9 +117,10 @@ function rel(p: string, projectPath: string): string {
|
|
|
112
117
|
/**
|
|
113
118
|
* What to say when activation threw partway through.
|
|
114
119
|
*
|
|
115
|
-
* The hard part is that a failure is NOT always a no-op.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
120
|
+
* The hard part is that a failure is NOT always a no-op. At first adoption
|
|
121
|
+
* activation moves up to three hand-written files in sequence and
|
|
122
|
+
* `settings.json` goes first, so a throw on the second leaves it already in
|
|
123
|
+
* the backup. The old message said "Nothing was
|
|
118
124
|
* activated" unconditionally, which in exactly that case is false about the
|
|
119
125
|
* user's own file — and `.magus-backups/` is gitignored, so `git status` showed
|
|
120
126
|
* the deletion with no sign of the copy. It read as "magus deleted my settings".
|
|
@@ -172,8 +178,8 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
172
178
|
});
|
|
173
179
|
// Collected as they move, NOT read from the resolved result.
|
|
174
180
|
//
|
|
175
|
-
//
|
|
176
|
-
// `settings.json` goes first, so a throw on the second leaves the
|
|
181
|
+
// First adoption moves up to three hand-written files in sequence
|
|
182
|
+
// and `settings.json` goes first, so a throw on the second leaves the
|
|
177
183
|
// user's file already in the backup — and the resolved value that
|
|
178
184
|
// would have listed it never arrives. This array is the only record
|
|
179
185
|
// the error path has, and without it the failure modal claimed
|
|
@@ -181,13 +187,11 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
181
187
|
// where they left it. `.magus-backups/` is gitignored, so git status
|
|
182
188
|
// showed the deletion and no sign of the copy.
|
|
183
189
|
const moved: DisplacedArtifact[] = [];
|
|
184
|
-
void applyProfileChoice(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
.then(({ profileId, displaced }) => {
|
|
190
|
+
void applyProfileChoice(choice, need.manifest, projectPath, {
|
|
191
|
+
onDisplace: (artifact) => moved.push(artifact),
|
|
192
|
+
})
|
|
193
|
+
.then((result: ActivationResult) => {
|
|
194
|
+
const { profileId, displaced } = result;
|
|
191
195
|
dispatch({
|
|
192
196
|
type: "SHOW_MODAL",
|
|
193
197
|
modal: {
|
|
@@ -198,6 +202,7 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
198
202
|
displaced,
|
|
199
203
|
projectPath,
|
|
200
204
|
automatic,
|
|
205
|
+
activationNotes(result),
|
|
201
206
|
),
|
|
202
207
|
variant: "success",
|
|
203
208
|
onDismiss: () => {
|
|
@@ -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(
|
|
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(
|
|
1210
|
+
await modal.message(
|
|
1211
|
+
"Error",
|
|
1212
|
+
error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
|
|
1213
|
+
"error",
|
|
1214
|
+
);
|
|
1206
1215
|
}
|
|
1207
1216
|
};
|
|
1208
1217
|
|
|
@@ -14,8 +14,11 @@ import {
|
|
|
14
14
|
readSettings,
|
|
15
15
|
writeSettings,
|
|
16
16
|
} from "../../services/claude-settings.js";
|
|
17
|
+
import { reconcileActiveProfile } from "../../services/profile-absorb.js";
|
|
18
|
+
import { activationNotes } from "../../services/profile-activate.js";
|
|
19
|
+
import { switchToProfile } from "../../services/profile-state.js";
|
|
17
20
|
import {
|
|
18
|
-
|
|
21
|
+
applyUserProfile,
|
|
19
22
|
deleteProfile,
|
|
20
23
|
exportProfileToJson,
|
|
21
24
|
importProfileFromJson,
|
|
@@ -23,12 +26,14 @@ import {
|
|
|
23
26
|
renameProfile,
|
|
24
27
|
} from "../../services/profiles.js";
|
|
25
28
|
import { writableMap } from "../../services/settings-file.js";
|
|
29
|
+
import { LiveFileRefusal } from "../../services/tracked-live-files.js";
|
|
26
30
|
import {
|
|
27
31
|
ClipboardUnavailableError,
|
|
28
32
|
readClipboard,
|
|
29
33
|
writeClipboard,
|
|
30
34
|
} from "../../utils/clipboard.js";
|
|
31
35
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
36
|
+
import { describeOutcome } from "../hooks/useProfileStartupModal.js";
|
|
32
37
|
import {
|
|
33
38
|
type ProfileListItem,
|
|
34
39
|
buildProfileListItems,
|
|
@@ -156,45 +161,98 @@ export function ProfilesScreen() {
|
|
|
156
161
|
}
|
|
157
162
|
}
|
|
158
163
|
await writeSettings(settings, state.projectPath);
|
|
164
|
+
// In a profiled project the merge is a change to the active profile:
|
|
165
|
+
// record it there now, rather than at the next magus command.
|
|
166
|
+
const recorded = await reconcileActiveProfile(state.projectPath);
|
|
159
167
|
modal.hideModal();
|
|
160
168
|
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
161
169
|
await modal.message(
|
|
162
170
|
"Applied",
|
|
163
|
-
|
|
171
|
+
[
|
|
172
|
+
`Profile "${profile.name}" merged into project settings.`,
|
|
173
|
+
...recorded.lines,
|
|
174
|
+
].join("\n"),
|
|
164
175
|
"success",
|
|
165
176
|
);
|
|
166
177
|
} catch (error) {
|
|
167
178
|
modal.hideModal();
|
|
168
179
|
await modal.message(
|
|
169
180
|
"Error",
|
|
170
|
-
|
|
181
|
+
error instanceof LiveFileRefusal
|
|
182
|
+
? error.message
|
|
183
|
+
: `Failed to apply profile: ${error}`,
|
|
171
184
|
"error",
|
|
172
185
|
);
|
|
173
186
|
}
|
|
174
187
|
};
|
|
175
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Apply a saved profile.
|
|
191
|
+
*
|
|
192
|
+
* A PROJECT profile is switched to, through the same path as `magus profile
|
|
193
|
+
* switch`: what was changed in the live config goes into the profile being
|
|
194
|
+
* left, then this one is rendered. Writing its enabledPlugins straight into
|
|
195
|
+
* `.claude/settings.json` made the apply invisible to the profile and undone
|
|
196
|
+
* by the next render.
|
|
197
|
+
*
|
|
198
|
+
* A USER-scope profile is a template in `~/.claude/profiles.json` and applies
|
|
199
|
+
* to `~/.claude/settings.json` only — never to this project's live files,
|
|
200
|
+
* which belong to its active profile.
|
|
201
|
+
*/
|
|
176
202
|
const handleApply = async () => {
|
|
177
203
|
if (selectedItem?.kind !== "saved") return;
|
|
178
204
|
const selectedProfile = selectedItem.entry;
|
|
179
205
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
label: "Project — .claude/settings.json (this project)",
|
|
187
|
-
value: "project",
|
|
188
|
-
},
|
|
189
|
-
],
|
|
190
|
-
);
|
|
191
|
-
if (scopeChoice === null) return;
|
|
206
|
+
if (selectedProfile.scope === "project") {
|
|
207
|
+
const confirmed = await modal.confirm(
|
|
208
|
+
"Switch profile",
|
|
209
|
+
`Make "${selectedProfile.name}" this project's active profile?\n\nIts plugins, MCP servers, model routing and skills replace the current ones. Anything changed in the current config is saved into the profile you are leaving first.`,
|
|
210
|
+
);
|
|
211
|
+
if (!confirmed) return;
|
|
192
212
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
modal.loading(`Switching to "${selectedProfile.name}"...`);
|
|
214
|
+
try {
|
|
215
|
+
const result = await switchToProfile(
|
|
216
|
+
state.projectPath,
|
|
217
|
+
selectedProfile.id,
|
|
218
|
+
{ onDisplace: () => {} },
|
|
219
|
+
);
|
|
220
|
+
modal.hideModal();
|
|
221
|
+
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
222
|
+
if (result.problems.length > 0 || !result.activation) {
|
|
223
|
+
await modal.message(
|
|
224
|
+
"Could not switch",
|
|
225
|
+
[...result.lines, ...result.problems].join("\n"),
|
|
226
|
+
"error",
|
|
227
|
+
);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const outcome = describeOutcome(
|
|
231
|
+
result.activation.profileId,
|
|
232
|
+
result.activation.displaced,
|
|
233
|
+
state.projectPath,
|
|
234
|
+
false,
|
|
235
|
+
activationNotes(result.activation),
|
|
236
|
+
);
|
|
237
|
+
await modal.message(
|
|
238
|
+
"Profile active",
|
|
239
|
+
result.lines.length > 0
|
|
240
|
+
? `${result.lines.join("\n")}\n\n${outcome}`
|
|
241
|
+
: outcome,
|
|
242
|
+
"success",
|
|
243
|
+
);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
modal.hideModal();
|
|
246
|
+
await modal.message(
|
|
247
|
+
"Error",
|
|
248
|
+
error instanceof LiveFileRefusal
|
|
249
|
+
? error.message
|
|
250
|
+
: `Failed to switch profile: ${error}`,
|
|
251
|
+
"error",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
198
256
|
|
|
199
257
|
const pluginCount = Object.keys(selectedProfile.plugins ?? {}).length;
|
|
200
258
|
const emptyWarning =
|
|
@@ -204,30 +262,27 @@ export function ProfilesScreen() {
|
|
|
204
262
|
|
|
205
263
|
const confirmed = await modal.confirm(
|
|
206
264
|
"Confirm Apply",
|
|
207
|
-
`This will REPLACE all enabledPlugins in
|
|
265
|
+
`This will REPLACE all enabledPlugins in ~/.claude/settings.json (every project on this machine).${emptyWarning}\n\nContinue?`,
|
|
208
266
|
);
|
|
209
267
|
if (!confirmed) return;
|
|
210
268
|
|
|
211
269
|
modal.loading(`Applying "${selectedProfile.name}"...`);
|
|
212
270
|
try {
|
|
213
|
-
await
|
|
214
|
-
selectedProfile.id,
|
|
215
|
-
selectedProfile.scope,
|
|
216
|
-
targetScope,
|
|
217
|
-
state.projectPath,
|
|
218
|
-
);
|
|
271
|
+
await applyUserProfile(selectedProfile);
|
|
219
272
|
modal.hideModal();
|
|
220
273
|
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
221
274
|
await modal.message(
|
|
222
275
|
"Applied",
|
|
223
|
-
`Profile "${selectedProfile.name}" applied to
|
|
276
|
+
`Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
|
|
224
277
|
"success",
|
|
225
278
|
);
|
|
226
279
|
} catch (error) {
|
|
227
280
|
modal.hideModal();
|
|
228
281
|
await modal.message(
|
|
229
282
|
"Error",
|
|
230
|
-
|
|
283
|
+
error instanceof LiveFileRefusal
|
|
284
|
+
? error.message
|
|
285
|
+
: `Failed to apply profile: ${error}`,
|
|
231
286
|
"error",
|
|
232
287
|
);
|
|
233
288
|
}
|
|
@@ -535,7 +535,8 @@ export function StylesScreen() {
|
|
|
535
535
|
? `profile "${result.profile}"`
|
|
536
536
|
: "this project";
|
|
537
537
|
// Say plainly when the manifest was NOT updated: without that record a
|
|
538
|
-
//
|
|
538
|
+
// the next activation renders settings.json from the manifest and
|
|
539
|
+
// drops the style.
|
|
539
540
|
const durability =
|
|
540
541
|
result.profile && !result.recordedInManifest
|
|
541
542
|
? " — not in profiles.json, so `magus install` will drop it"
|
package/src/ui/state/types.ts
CHANGED
|
@@ -306,7 +306,7 @@ export interface ModelsScreenState {
|
|
|
306
306
|
* True while an apply or a clear is writing.
|
|
307
307
|
*
|
|
308
308
|
* App state, not screen state, because the screen UNMOUNTS on a tab switch.
|
|
309
|
-
* An apply rewrites the profile, re-
|
|
309
|
+
* An apply rewrites the profile, re-renders its live files and registers a hook —
|
|
310
310
|
* two of those running at once would interleave writes to the same files.
|
|
311
311
|
*/
|
|
312
312
|
isApplying: boolean;
|