magus-cli 7.4.0 → 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__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- 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__/stale-load-repair.test.ts +5 -4
- 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 +152 -44
- package/src/cli/install.ts +92 -127
- 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/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/gitignore-service.ts +39 -22
- 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 +14 -8
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tool-probe.ts +48 -3
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
- 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
|
|
@@ -135,16 +135,22 @@ type CheckedMap<V> = unknown extends V
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* {@link writableMap} for a list the caller pushes into or filters —
|
|
138
|
-
* `enabledMcpjsonServers
|
|
138
|
+
* `enabledMcpjsonServers`, a gitignore manifest's `ignore` and `track`. Present
|
|
139
|
+
* but not an array throws the same way.
|
|
139
140
|
*/
|
|
140
141
|
export function writableList<S extends object, K extends keyof S & string>(
|
|
141
142
|
settings: S,
|
|
142
143
|
key: K,
|
|
143
144
|
settingsPath: string,
|
|
144
|
-
): S[K] {
|
|
145
|
-
return checkedEntry(settings, key, settingsPath, key, "array")
|
|
145
|
+
): CheckedList<S[K]> {
|
|
146
|
+
return checkedEntry(settings, key, settingsPath, key, "array") as CheckedList<
|
|
147
|
+
S[K]
|
|
148
|
+
>;
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
/** {@link CheckedMap} for a list: an untyped key comes back as the array it is. */
|
|
152
|
+
type CheckedList<V> = unknown extends V ? unknown[] | undefined : V;
|
|
153
|
+
|
|
148
154
|
function checkedEntry<S extends object, K extends keyof S & string>(
|
|
149
155
|
settings: S,
|
|
150
156
|
key: K,
|
|
@@ -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
|
}
|
|
@@ -1439,6 +1439,36 @@ async function isNpmDefaultPrefix(
|
|
|
1439
1439
|
return real !== null && path.resolve(real) === root;
|
|
1440
1440
|
}
|
|
1441
1441
|
|
|
1442
|
+
/**
|
|
1443
|
+
* Is `onPath` — the entry PATH actually runs, before any link is followed —
|
|
1444
|
+
* the launcher the native installer maintains beside `dataHome`?
|
|
1445
|
+
*
|
|
1446
|
+
* The launcher lives one level up from the data home, in `bin`:
|
|
1447
|
+
* `~/.local/share` → `~/.local/bin/claude`. The comparison is on the
|
|
1448
|
+
* DIRECTORIES, never on the files: both the launcher and a hand-made link
|
|
1449
|
+
* resolve into `versions/<version>`, which is precisely what has to be told
|
|
1450
|
+
* apart. Their real paths are compared too, so a symlinked `~/.local` still
|
|
1451
|
+
* matches.
|
|
1452
|
+
*/
|
|
1453
|
+
async function isNativeLauncher(
|
|
1454
|
+
s: ProbeSession,
|
|
1455
|
+
onPath: string,
|
|
1456
|
+
dataHome: string,
|
|
1457
|
+
): Promise<boolean> {
|
|
1458
|
+
const launcherDir = path.resolve(dataHome, "..", "bin");
|
|
1459
|
+
const entryDir = path.resolve(path.dirname(onPath));
|
|
1460
|
+
if (launcherDir === entryDir) return true;
|
|
1461
|
+
const [launcherReal, entryReal] = await Promise.all([
|
|
1462
|
+
s.io.realpath(launcherDir),
|
|
1463
|
+
s.io.realpath(entryDir),
|
|
1464
|
+
]);
|
|
1465
|
+
return (
|
|
1466
|
+
launcherReal !== null &&
|
|
1467
|
+
entryReal !== null &&
|
|
1468
|
+
path.resolve(launcherReal) === path.resolve(entryReal)
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1442
1472
|
/**
|
|
1443
1473
|
* Claude Code's native installer keeps every release side by side under
|
|
1444
1474
|
* `<data>/claude/versions/<version>` and points PATH (`~/.local/bin/claude`) at
|
|
@@ -1451,12 +1481,23 @@ async function isNpmDefaultPrefix(
|
|
|
1451
1481
|
* number. The version is that name; the latest is npm's `latest` dist-tag for
|
|
1452
1482
|
* Claude Code, the channel the native installer tracks by default.
|
|
1453
1483
|
*
|
|
1484
|
+
* **Resolving into `versions/` is not enough: the entry PATH runs has to BE the
|
|
1485
|
+
* native launcher.** A hand-made link earlier on PATH — `/usr/local/bin/claude`
|
|
1486
|
+
* pointed at a release directory by hand, a wrapper someone wrote — resolves
|
|
1487
|
+
* into exactly the same place, and planning `claude update` for it would be a
|
|
1488
|
+
* plan that changes nothing: the updater rewrites the launcher, and PATH goes
|
|
1489
|
+
* on running the other file at the old version. Every run would find it behind
|
|
1490
|
+
* and plan the same no-op again. So the check is on `onPath`, the unresolved
|
|
1491
|
+
* entry, and a copy that is not the launcher falls through to the spec's own
|
|
1492
|
+
* installer — which reports it as a second copy it does not own.
|
|
1493
|
+
*
|
|
1454
1494
|
* True when it recognised the layout; `f` then holds the whole answer.
|
|
1455
1495
|
*/
|
|
1456
1496
|
async function probeClaudeNative(
|
|
1457
1497
|
s: ProbeSession,
|
|
1458
1498
|
spec: BinRequirement,
|
|
1459
1499
|
f: ToolFacts,
|
|
1500
|
+
onPath: string,
|
|
1460
1501
|
resolvedPath: string,
|
|
1461
1502
|
): Promise<boolean> {
|
|
1462
1503
|
// The cheap, pure shape test first, so no other tool pays for the IO below.
|
|
@@ -1471,7 +1512,10 @@ async function probeClaudeNative(
|
|
|
1471
1512
|
let owned = false;
|
|
1472
1513
|
for (const home of dataHomes) {
|
|
1473
1514
|
const versions = path.resolve(home, "claude", "versions");
|
|
1474
|
-
|
|
1515
|
+
if (versions !== dir && (await s.io.realpath(versions)) !== dir) continue;
|
|
1516
|
+
// The directory is the installer's. Only the launcher it maintains
|
|
1517
|
+
// there speaks for it.
|
|
1518
|
+
owned = await isNativeLauncher(s, onPath, home);
|
|
1475
1519
|
if (owned) break;
|
|
1476
1520
|
}
|
|
1477
1521
|
if (!owned) return false;
|
|
@@ -1561,8 +1605,9 @@ async function probeInto(
|
|
|
1561
1605
|
f.resolvedPath = resolvedPath;
|
|
1562
1606
|
|
|
1563
1607
|
// A file Claude Code's native installer owns answers for itself, whatever
|
|
1564
|
-
// installer the spec names.
|
|
1565
|
-
|
|
1608
|
+
// installer the spec names. `onPath` as well as `resolvedPath`: ownership
|
|
1609
|
+
// turns on which file PATH runs, not only on where it points.
|
|
1610
|
+
if (await probeClaudeNative(s, spec, f, onPath, resolvedPath)) return;
|
|
1566
1611
|
|
|
1567
1612
|
const pinned = pinOf(spec) !== undefined;
|
|
1568
1613
|
const via: BinInstaller = spec.via;
|
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
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
2
2
|
import React, { useRef, useState } from "react";
|
|
3
|
-
import { useKeyLayer } from "../../input/KeyRouter.js";
|
|
3
|
+
import { useKeyLayer, usePasteLayer } from "../../input/KeyRouter.js";
|
|
4
4
|
import { type KeyPress, isScrollKey } from "../../input/key-dispatch.js";
|
|
5
5
|
import { useApp, useModal } from "../../state/AppContext.js";
|
|
6
6
|
import { useDimensions } from "../../state/DimensionsContext.js";
|
|
@@ -23,6 +23,12 @@ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
|
|
|
23
23
|
* Enter that confirms a dialog confirms only the dialog. Ctrl+C still quits: it
|
|
24
24
|
* is taken above every layer, in src/tui.tsx.
|
|
25
25
|
*
|
|
26
|
+
* It takes every PASTE for the same reason, and does nothing with it: a search
|
|
27
|
+
* box on the screen behind still has focus, and without this its query would
|
|
28
|
+
* grow from a paste aimed at the dialog in front of it. The event itself is not
|
|
29
|
+
* stopped, so the input modal's focused `<input>` still receives it — the
|
|
30
|
+
* arrangement the `input` case below already relies on for keys.
|
|
31
|
+
*
|
|
26
32
|
* - confirm: y / Enter confirm; n / Esc / q cancel.
|
|
27
33
|
* - select: ↑↓ (j/k) move, Enter picks, Esc / q cancel (a no-op for a
|
|
28
34
|
* question that must be answered).
|
|
@@ -79,6 +85,8 @@ export function ModalContainer() {
|
|
|
79
85
|
}
|
|
80
86
|
};
|
|
81
87
|
|
|
88
|
+
usePasteLayer("modal", modal !== null, () => true);
|
|
89
|
+
|
|
82
90
|
useKeyLayer("modal", modal !== null, (key) => {
|
|
83
91
|
if (!modal) return false;
|
|
84
92
|
|
|
@@ -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: () => {
|