magus-cli 7.4.1 → 7.5.0
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 +4 -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 +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- 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 +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +132 -38
- package/src/cli/install.ts +58 -118
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- 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/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- 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
package/src/services/resolver.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
} from "../types/index.js";
|
|
22
22
|
import { readManifest } from "./manifest.js";
|
|
23
23
|
import { resolvePluginBinRequirements } from "./plugin-requires.js";
|
|
24
|
-
import { activeProfile } from "./
|
|
24
|
+
import { activeProfile } from "./profile-live.js";
|
|
25
25
|
|
|
26
26
|
export interface ResolveOptions {
|
|
27
27
|
/**
|
|
@@ -236,9 +236,14 @@ export async function resolveProfile(
|
|
|
236
236
|
const plugins = normalizePlugins(entry.plugins);
|
|
237
237
|
const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
|
|
238
238
|
|
|
239
|
+
const disabledPlugins = Object.entries(entry.plugins ?? {})
|
|
240
|
+
.filter(([, ver]) => ver === false)
|
|
241
|
+
.map(([id]) => id);
|
|
242
|
+
|
|
239
243
|
return {
|
|
240
244
|
marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
|
|
241
245
|
plugins,
|
|
246
|
+
...(disabledPlugins.length > 0 ? { disabledPlugins } : {}),
|
|
242
247
|
mcpServers: { ...(entry.mcpServers ?? {}) },
|
|
243
248
|
bins,
|
|
244
249
|
skills: [...(entry.skills ?? [])],
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two rules, and both exist because of damage they prevent:
|
|
5
5
|
*
|
|
6
|
-
* 1. **Write IN PLACE, never remove-and-recreate.**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* 1. **Write IN PLACE, never remove-and-recreate.** A settings file may be a
|
|
7
|
+
* symlink somebody set up deliberately, and `writeFile` follows it — which
|
|
8
|
+
* is what we want. Unlinking the file first would break the link and leave
|
|
9
|
+
* the next read looking at a fresh, empty file. (magus's own profile files
|
|
10
|
+
* are real files; `profile-live.ts` replaces them via a rename.)
|
|
11
11
|
* 2. **Unparseable is an ERROR, not an empty object.** A settings file that
|
|
12
12
|
* fails to parse is a file we do not understand, usually because a human is
|
|
13
13
|
* mid-edit. Treating it as `{}` and writing our one key back is how a
|
|
@@ -40,6 +40,18 @@ export function getProjectSkillsDir(projectPath?: string): string {
|
|
|
40
40
|
return path.join(projectPath ?? process.cwd(), ".claude", "skills");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The folder a skill installs into: a slug of its display name, since display
|
|
45
|
+
* names can carry slashes and spaces. One definition, because the profile code
|
|
46
|
+
* must predict exactly where `installSkill` will write.
|
|
47
|
+
*/
|
|
48
|
+
export function skillFolderName(name: string): string {
|
|
49
|
+
return name
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
52
|
+
.replace(/^-|-$/g, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
// ─── GitHub Tree API ──────────────────────────────────────────────────────────
|
|
44
56
|
|
|
45
57
|
async function fetchGitTree(repo: string): Promise<GitTreeResponse> {
|
|
@@ -1003,10 +1015,7 @@ export async function installSkill(
|
|
|
1003
1015
|
}
|
|
1004
1016
|
|
|
1005
1017
|
// Use slug for directory name — display names can have slashes/spaces
|
|
1006
|
-
const dirName = skill.name
|
|
1007
|
-
.toLowerCase()
|
|
1008
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
1009
|
-
.replace(/^-|-$/g, "");
|
|
1018
|
+
const dirName = skillFolderName(skill.name);
|
|
1010
1019
|
|
|
1011
1020
|
const installDir =
|
|
1012
1021
|
scope === "user"
|
|
@@ -1090,10 +1099,7 @@ export async function uninstallSkill(
|
|
|
1090
1099
|
scope: "user" | "project",
|
|
1091
1100
|
projectPath?: string,
|
|
1092
1101
|
): Promise<void> {
|
|
1093
|
-
const dirName = skillName
|
|
1094
|
-
.toLowerCase()
|
|
1095
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
1096
|
-
.replace(/^-|-$/g, "");
|
|
1102
|
+
const dirName = skillFolderName(skillName);
|
|
1097
1103
|
|
|
1098
1104
|
const installDir =
|
|
1099
1105
|
scope === "user"
|
|
@@ -19,12 +19,11 @@
|
|
|
19
19
|
*
|
|
20
20
|
* ## Profile awareness
|
|
21
21
|
*
|
|
22
|
-
* `.claude/settings.json` is
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `profiles.<name>.settings.outputStyle`, which is the durable source.
|
|
22
|
+
* `.claude/settings.json` is rendered from the active profile when there is
|
|
23
|
+
* one, so the selection is recorded in `.claude/profiles.json` under
|
|
24
|
+
* `profiles.<name>.settings.outputStyle` — the durable source — and the live
|
|
25
|
+
* file is rendered again from it. Without a profile the live file is edited in
|
|
26
|
+
* place.
|
|
28
27
|
*
|
|
29
28
|
* The generated style file is named per-profile (`composed-<name>`) so two
|
|
30
29
|
* profiles can hold different styles without clobbering each other's file
|
|
@@ -43,8 +42,10 @@ import {
|
|
|
43
42
|
readManifest,
|
|
44
43
|
writeManifest,
|
|
45
44
|
} from "./manifest.js";
|
|
45
|
+
import { regenerateLiveFiles } from "./profile-activate.js";
|
|
46
|
+
import { activeProfile } from "./profile-live.js";
|
|
47
|
+
import { prepareProfileWrite } from "./profile-state.js";
|
|
46
48
|
import { updateSettingsFile } from "./settings-file.js";
|
|
47
|
-
import { activeProfile } from "./symlink-manager.js";
|
|
48
49
|
|
|
49
50
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
50
51
|
|
|
@@ -188,7 +189,7 @@ export interface StylesSnapshot {
|
|
|
188
189
|
settingsPath: string;
|
|
189
190
|
/** Name of the generated style, e.g. "composed" or "composed-dev". */
|
|
190
191
|
styleName: string;
|
|
191
|
-
/** Active profile name, when
|
|
192
|
+
/** Active profile name, when a profile is active (see `activeProfile`). */
|
|
192
193
|
profile: string | null;
|
|
193
194
|
/** The value currently in settings.json, whatever set it. */
|
|
194
195
|
currentOutputStyle: string | null;
|
|
@@ -1112,6 +1113,10 @@ export async function applyStyles({
|
|
|
1112
1113
|
throw new Error("Nothing selected. Pick at least one style.");
|
|
1113
1114
|
}
|
|
1114
1115
|
|
|
1116
|
+
// The gate before anything is written: whatever Claude Code wrote to the
|
|
1117
|
+
// live config goes into the profile, so the render below replaces nothing
|
|
1118
|
+
// unrecorded — and a live file that does not parse stops everything.
|
|
1119
|
+
await prepareProfileWrite(projectPath);
|
|
1115
1120
|
const profile = await activeProfile(projectPath);
|
|
1116
1121
|
const styleName = generatedStyleName(profile);
|
|
1117
1122
|
const stylePath = stylePathFor(projectPath, styleName);
|
|
@@ -1122,7 +1127,19 @@ export async function applyStyles({
|
|
|
1122
1127
|
|
|
1123
1128
|
await fs.ensureDir(path.dirname(stylePath));
|
|
1124
1129
|
await fs.writeFile(stylePath, contents, "utf8");
|
|
1125
|
-
|
|
1130
|
+
|
|
1131
|
+
// With a profile active, the manifest is where the selection lives and the
|
|
1132
|
+
// live file is rendered from it.
|
|
1133
|
+
let recordedInManifest = false;
|
|
1134
|
+
if (profile !== null) {
|
|
1135
|
+
recordedInManifest = await recordInManifest(
|
|
1136
|
+
projectPath,
|
|
1137
|
+
profile,
|
|
1138
|
+
styleName,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
if (recordedInManifest) await regenerateLiveFiles(projectPath);
|
|
1142
|
+
else await setOutputStyle(settingsPath, styleName);
|
|
1126
1143
|
|
|
1127
1144
|
// Verify by re-reading, not by trusting the write.
|
|
1128
1145
|
const verify = splitFrontmatter(await fs.readFile(stylePath, "utf8"));
|
|
@@ -1144,12 +1161,6 @@ export async function applyStyles({
|
|
|
1144
1161
|
throw new Error(`Verification failed — ${problems.join("; ")}`);
|
|
1145
1162
|
}
|
|
1146
1163
|
|
|
1147
|
-
const recordedInManifest = await recordInManifest(
|
|
1148
|
-
projectPath,
|
|
1149
|
-
profile,
|
|
1150
|
-
styleName,
|
|
1151
|
-
);
|
|
1152
|
-
|
|
1153
1164
|
// Write the declaration LAST, once the artifact is verified. It is the thing
|
|
1154
1165
|
// that gets committed and that teammates are told to match, so it must never
|
|
1155
1166
|
// claim a composition that failed to produce a valid file.
|
|
@@ -1268,6 +1279,25 @@ export async function createTeamStyle(
|
|
|
1268
1279
|
*/
|
|
1269
1280
|
export async function clearStyle(projectPath: string): Promise<void> {
|
|
1270
1281
|
const settingsPath = settingsPathFor(projectPath);
|
|
1282
|
+
// The gate first; a live file that does not parse stops everything.
|
|
1283
|
+
await prepareProfileWrite(projectPath);
|
|
1284
|
+
const profile = await activeProfile(projectPath);
|
|
1285
|
+
|
|
1286
|
+
if (profile !== null && (await fs.pathExists(getManifestPath(projectPath)))) {
|
|
1287
|
+
const manifest = await readManifest(projectPath);
|
|
1288
|
+
const entry = manifest.profiles[profile];
|
|
1289
|
+
if (entry?.settings && "outputStyle" in entry.settings) {
|
|
1290
|
+
// The guard above uses `in`, so assigning undefined would make a second
|
|
1291
|
+
// call a no-op that still leaves the key serialized into profiles.json.
|
|
1292
|
+
// biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
|
|
1293
|
+
delete entry.settings.outputStyle;
|
|
1294
|
+
entry.updatedAt = new Date().toISOString();
|
|
1295
|
+
await writeManifest(manifest, projectPath);
|
|
1296
|
+
}
|
|
1297
|
+
await regenerateLiveFiles(projectPath);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1271
1301
|
// The guard stays: a project that never had a style must not gain a
|
|
1272
1302
|
// settings.json just because the style was cleared.
|
|
1273
1303
|
if (await fs.pathExists(settingsPath)) {
|
|
@@ -1278,17 +1308,4 @@ export async function clearStyle(projectPath: string): Promise<void> {
|
|
|
1278
1308
|
delete settings.outputStyle;
|
|
1279
1309
|
});
|
|
1280
1310
|
}
|
|
1281
|
-
|
|
1282
|
-
const profile = await activeProfile(projectPath);
|
|
1283
|
-
if (!profile) return;
|
|
1284
|
-
if (!(await fs.pathExists(getManifestPath(projectPath)))) return;
|
|
1285
|
-
const manifest = await readManifest(projectPath);
|
|
1286
|
-
const entry = manifest.profiles[profile];
|
|
1287
|
-
if (!entry?.settings || !("outputStyle" in entry.settings)) return;
|
|
1288
|
-
// The guard above uses `in`, so assigning undefined would make a second call
|
|
1289
|
-
// a no-op that still leaves the key serialized into profiles.json.
|
|
1290
|
-
// biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
|
|
1291
|
-
delete entry.settings.outputStyle;
|
|
1292
|
-
entry.updatedAt = new Date().toISOString();
|
|
1293
|
-
await writeManifest(manifest, projectPath);
|
|
1294
1311
|
}
|
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,14 +452,20 @@ 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[] };
|
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: () => {
|
|
@@ -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,
|
|
@@ -29,6 +32,7 @@ import {
|
|
|
29
32
|
writeClipboard,
|
|
30
33
|
} from "../../utils/clipboard.js";
|
|
31
34
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
35
|
+
import { describeOutcome } from "../hooks/useProfileStartupModal.js";
|
|
32
36
|
import {
|
|
33
37
|
type ProfileListItem,
|
|
34
38
|
buildProfileListItems,
|
|
@@ -156,11 +160,17 @@ export function ProfilesScreen() {
|
|
|
156
160
|
}
|
|
157
161
|
}
|
|
158
162
|
await writeSettings(settings, state.projectPath);
|
|
163
|
+
// In a profiled project the merge is a change to the active profile:
|
|
164
|
+
// record it there now, rather than at the next magus command.
|
|
165
|
+
const recorded = await reconcileActiveProfile(state.projectPath);
|
|
159
166
|
modal.hideModal();
|
|
160
167
|
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
161
168
|
await modal.message(
|
|
162
169
|
"Applied",
|
|
163
|
-
|
|
170
|
+
[
|
|
171
|
+
`Profile "${profile.name}" merged into project settings.`,
|
|
172
|
+
...recorded.lines,
|
|
173
|
+
].join("\n"),
|
|
164
174
|
"success",
|
|
165
175
|
);
|
|
166
176
|
} catch (error) {
|
|
@@ -173,28 +183,71 @@ export function ProfilesScreen() {
|
|
|
173
183
|
}
|
|
174
184
|
};
|
|
175
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Apply a saved profile.
|
|
188
|
+
*
|
|
189
|
+
* A PROJECT profile is switched to, through the same path as `magus profile
|
|
190
|
+
* switch`: what was changed in the live config goes into the profile being
|
|
191
|
+
* left, then this one is rendered. Writing its enabledPlugins straight into
|
|
192
|
+
* `.claude/settings.json` made the apply invisible to the profile and undone
|
|
193
|
+
* by the next render.
|
|
194
|
+
*
|
|
195
|
+
* A USER-scope profile is a template in `~/.claude/profiles.json` and applies
|
|
196
|
+
* to `~/.claude/settings.json` only — never to this project's live files,
|
|
197
|
+
* which belong to its active profile.
|
|
198
|
+
*/
|
|
176
199
|
const handleApply = async () => {
|
|
177
200
|
if (selectedItem?.kind !== "saved") return;
|
|
178
201
|
const selectedProfile = selectedItem.entry;
|
|
179
202
|
|
|
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;
|
|
203
|
+
if (selectedProfile.scope === "project") {
|
|
204
|
+
const confirmed = await modal.confirm(
|
|
205
|
+
"Switch profile",
|
|
206
|
+
`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.`,
|
|
207
|
+
);
|
|
208
|
+
if (!confirmed) return;
|
|
192
209
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
modal.loading(`Switching to "${selectedProfile.name}"...`);
|
|
211
|
+
try {
|
|
212
|
+
const result = await switchToProfile(
|
|
213
|
+
state.projectPath,
|
|
214
|
+
selectedProfile.id,
|
|
215
|
+
{ onDisplace: () => {} },
|
|
216
|
+
);
|
|
217
|
+
modal.hideModal();
|
|
218
|
+
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
219
|
+
if (result.problems.length > 0 || !result.activation) {
|
|
220
|
+
await modal.message(
|
|
221
|
+
"Could not switch",
|
|
222
|
+
[...result.lines, ...result.problems].join("\n"),
|
|
223
|
+
"error",
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const outcome = describeOutcome(
|
|
228
|
+
result.activation.profileId,
|
|
229
|
+
result.activation.displaced,
|
|
230
|
+
state.projectPath,
|
|
231
|
+
false,
|
|
232
|
+
activationNotes(result.activation),
|
|
233
|
+
);
|
|
234
|
+
await modal.message(
|
|
235
|
+
"Profile active",
|
|
236
|
+
result.lines.length > 0
|
|
237
|
+
? `${result.lines.join("\n")}\n\n${outcome}`
|
|
238
|
+
: outcome,
|
|
239
|
+
"success",
|
|
240
|
+
);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
modal.hideModal();
|
|
243
|
+
await modal.message(
|
|
244
|
+
"Error",
|
|
245
|
+
`Failed to switch profile: ${error}`,
|
|
246
|
+
"error",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
198
251
|
|
|
199
252
|
const pluginCount = Object.keys(selectedProfile.plugins ?? {}).length;
|
|
200
253
|
const emptyWarning =
|
|
@@ -204,23 +257,18 @@ export function ProfilesScreen() {
|
|
|
204
257
|
|
|
205
258
|
const confirmed = await modal.confirm(
|
|
206
259
|
"Confirm Apply",
|
|
207
|
-
`This will REPLACE all enabledPlugins in
|
|
260
|
+
`This will REPLACE all enabledPlugins in ~/.claude/settings.json (every project on this machine).${emptyWarning}\n\nContinue?`,
|
|
208
261
|
);
|
|
209
262
|
if (!confirmed) return;
|
|
210
263
|
|
|
211
264
|
modal.loading(`Applying "${selectedProfile.name}"...`);
|
|
212
265
|
try {
|
|
213
|
-
await
|
|
214
|
-
selectedProfile.id,
|
|
215
|
-
selectedProfile.scope,
|
|
216
|
-
targetScope,
|
|
217
|
-
state.projectPath,
|
|
218
|
-
);
|
|
266
|
+
await applyUserProfile(selectedProfile);
|
|
219
267
|
modal.hideModal();
|
|
220
268
|
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
221
269
|
await modal.message(
|
|
222
270
|
"Applied",
|
|
223
|
-
`Profile "${selectedProfile.name}" applied to
|
|
271
|
+
`Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
|
|
224
272
|
"success",
|
|
225
273
|
);
|
|
226
274
|
} catch (error) {
|
|
@@ -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;
|