magus-cli 7.0.0 → 7.2.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/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +58 -17
- package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
- package/src/__tests__/cli-update-view.test.ts +100 -21
- package/src/__tests__/local-marketplace-scan.test.ts +74 -0
- package/src/__tests__/marketplace-refresh.test.ts +12 -7
- package/src/__tests__/plugin-row-text.test.ts +33 -24
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/__tests__/theme-env.test.ts +29 -0
- package/src/__tests__/update-plan.test.ts +57 -34
- package/src/__tests__/version-unknown-state.test.ts +29 -14
- package/src/cli/ansi.ts +58 -11
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/router.ts +59 -2
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +237 -45
- package/src/cli/update.ts +59 -29
- package/src/main.tsx +11 -2
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/manifest.ts +21 -0
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/resolver.ts +33 -0
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-plan.ts +23 -66
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -3
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +142 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
|
@@ -816,24 +816,15 @@ async function writeKnownMarketplaces(
|
|
|
816
816
|
}
|
|
817
817
|
|
|
818
818
|
/**
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
known[marketplaceName].autoUpdate = autoUpdate;
|
|
829
|
-
await writeKnownMarketplaces(known);
|
|
830
|
-
return true;
|
|
831
|
-
}
|
|
832
|
-
return false; // Marketplace not yet installed by Claude Code
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/**
|
|
836
|
-
* Get autoUpdate status for a marketplace
|
|
819
|
+
* Read a marketplace's `autoUpdate` flag — Claude Code's refresh policy.
|
|
820
|
+
*
|
|
821
|
+
* READ ONLY, and there is deliberately no setter. `autoUpdate` is Claude Code's
|
|
822
|
+
* field (50 occurrences in the 2.1.269 binary, with its own manifest-schema
|
|
823
|
+
* entry and a "Synced autoUpdate= from settings" path), so Claude Code decides
|
|
824
|
+
* its value. magus used to write `true` into every MadAppGang entry on TUI
|
|
825
|
+
* startup, which is a direct write to `known_marketplaces.json` — the file at
|
|
826
|
+
* the top of CLAUDE.md's never-write-directly list — and it fought Claude Code's
|
|
827
|
+
* own sync for ownership of the same byte.
|
|
837
828
|
*/
|
|
838
829
|
export async function getMarketplaceAutoUpdate(
|
|
839
830
|
marketplaceName: string,
|
|
@@ -843,7 +834,6 @@ export async function getMarketplaceAutoUpdate(
|
|
|
843
834
|
}
|
|
844
835
|
|
|
845
836
|
export interface MarketplaceRecoveryResult {
|
|
846
|
-
enabledAutoUpdate: string[];
|
|
847
837
|
removed: string[];
|
|
848
838
|
reregistered: string[];
|
|
849
839
|
}
|
|
@@ -1189,24 +1179,19 @@ export async function migrateMarketplaceRename(
|
|
|
1189
1179
|
return result;
|
|
1190
1180
|
}
|
|
1191
1181
|
|
|
1192
|
-
/**
|
|
1193
|
-
* Check if a marketplace is from Magus (MadAppGang)
|
|
1194
|
-
*/
|
|
1195
|
-
function isMadAppGangMarketplace(entry: KnownMarketplaceEntry): boolean {
|
|
1196
|
-
const repo = entry.source?.repo?.toLowerCase() || "";
|
|
1197
|
-
return repo.includes("madappgang");
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1182
|
/**
|
|
1201
1183
|
* Recover/sync marketplace settings:
|
|
1202
|
-
* - Enable autoUpdate for Magus marketplaces that don't have it set
|
|
1203
1184
|
* - Remove entries for marketplaces whose installLocation no longer exists
|
|
1204
1185
|
* - Fix stale "directory" source for known Magus marketplaces that should be "github"
|
|
1186
|
+
*
|
|
1187
|
+
* It does NOT touch `autoUpdate`. It used to set it to `true` on every
|
|
1188
|
+
* MadAppGang entry whose flag was unset, on every TUI startup — writing a
|
|
1189
|
+
* Claude Code-owned field that Claude Code also syncs from settings. See
|
|
1190
|
+
* {@link getMarketplaceAutoUpdate}.
|
|
1205
1191
|
*/
|
|
1206
1192
|
export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryResult> {
|
|
1207
1193
|
const known = await readKnownMarketplaces();
|
|
1208
1194
|
const result: MarketplaceRecoveryResult = {
|
|
1209
|
-
enabledAutoUpdate: [],
|
|
1210
1195
|
removed: [],
|
|
1211
1196
|
reregistered: [],
|
|
1212
1197
|
};
|
|
@@ -1249,21 +1234,11 @@ export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryR
|
|
|
1249
1234
|
}
|
|
1250
1235
|
}
|
|
1251
1236
|
|
|
1252
|
-
// Enable autoUpdate if not set - only for Magus (MadAppGang) marketplaces
|
|
1253
|
-
if (entry.autoUpdate === undefined && isMadAppGangMarketplace(entry)) {
|
|
1254
|
-
entry.autoUpdate = true;
|
|
1255
|
-
result.enabledAutoUpdate.push(name);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
1237
|
updatedKnown[name] = entry;
|
|
1259
1238
|
}
|
|
1260
1239
|
|
|
1261
1240
|
// Write back if any changes were made
|
|
1262
|
-
if (
|
|
1263
|
-
result.enabledAutoUpdate.length > 0 ||
|
|
1264
|
-
result.removed.length > 0 ||
|
|
1265
|
-
result.reregistered.length > 0
|
|
1266
|
-
) {
|
|
1241
|
+
if (result.removed.length > 0 || result.reregistered.length > 0) {
|
|
1267
1242
|
await writeKnownMarketplaces(updatedKnown);
|
|
1268
1243
|
}
|
|
1269
1244
|
|
|
@@ -64,17 +64,73 @@ interface KnownMarketplaceEntry {
|
|
|
64
64
|
type KnownMarketplaces = Record<string, KnownMarketplaceEntry>;
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Read known_marketplaces.json
|
|
67
|
+
* Read known_marketplaces.json, or null when it cannot be read honestly.
|
|
68
|
+
*
|
|
69
|
+
* The null matters. {@link readKnownMarketplaces} collapses "absent",
|
|
70
|
+
* "unparseable" and "genuinely empty" into `{}`, which is fine for a lookup and
|
|
71
|
+
* catastrophic for a filter: intersecting the marketplaces directory against an
|
|
72
|
+
* empty registry deletes every marketplace, and every installed plugin then
|
|
73
|
+
* reports as no longer published. A registry we could not read is UNKNOWN, and
|
|
74
|
+
* unknown must not render as a determinate negative.
|
|
68
75
|
*/
|
|
69
|
-
async function
|
|
76
|
+
async function readKnownMarketplacesOrNull(): Promise<KnownMarketplaces | null> {
|
|
70
77
|
try {
|
|
71
|
-
if (await fs.pathExists(KNOWN_MARKETPLACES_FILE))
|
|
72
|
-
|
|
78
|
+
if (!(await fs.pathExists(KNOWN_MARKETPLACES_FILE))) return null;
|
|
79
|
+
const parsed = await fs.readJson(KNOWN_MARKETPLACES_FILE);
|
|
80
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
81
|
+
return null;
|
|
73
82
|
}
|
|
83
|
+
return parsed as KnownMarketplaces;
|
|
74
84
|
} catch {
|
|
75
|
-
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read known_marketplaces.json (Claude Code's internal marketplace tracking)
|
|
91
|
+
*/
|
|
92
|
+
async function readKnownMarketplaces(): Promise<KnownMarketplaces> {
|
|
93
|
+
return (await readKnownMarketplacesOrNull()) ?? {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Directory names a parsed `known_marketplaces.json` registers, or null when it
|
|
98
|
+
* says nothing usable.
|
|
99
|
+
*
|
|
100
|
+
* Pure, and exported, because `scanLocalMarketplaces` resolves its paths from
|
|
101
|
+
* `os.homedir()` at module load and cannot be pointed at a temp directory. The
|
|
102
|
+
* decision this encodes is the part worth testing; the IO around it is not.
|
|
103
|
+
*
|
|
104
|
+
* Keyed by BOTH the registry key and the basename of `installLocation`: the two
|
|
105
|
+
* are normally equal and nothing enforces it, and dropping a real marketplace
|
|
106
|
+
* is far worse than keeping a stray directory.
|
|
107
|
+
*
|
|
108
|
+
* An empty object is an ANSWER — nothing is registered — and filters everything
|
|
109
|
+
* out. Only unusable input is null. The distinction lives in
|
|
110
|
+
* {@link readKnownMarketplacesOrNull}, which returns null for an absent file,
|
|
111
|
+
* so a machine that has never added a marketplace stays permissive.
|
|
112
|
+
*/
|
|
113
|
+
export function registeredMarketplaceNames(
|
|
114
|
+
parsed: unknown,
|
|
115
|
+
): Set<string> | null {
|
|
116
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const names = new Set<string>();
|
|
120
|
+
for (const [name, entry] of Object.entries(
|
|
121
|
+
parsed as Record<string, unknown>,
|
|
122
|
+
)) {
|
|
123
|
+
names.add(name);
|
|
124
|
+
const loc = (entry as { installLocation?: unknown })?.installLocation;
|
|
125
|
+
if (typeof loc === "string" && loc.length > 0) {
|
|
126
|
+
names.add(path.basename(loc));
|
|
127
|
+
}
|
|
76
128
|
}
|
|
77
|
-
return
|
|
129
|
+
return names;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function registeredDirNames(): Promise<Set<string> | null> {
|
|
133
|
+
return registeredMarketplaceNames(await readKnownMarketplacesOrNull());
|
|
78
134
|
}
|
|
79
135
|
|
|
80
136
|
/**
|
|
@@ -321,6 +377,19 @@ export async function scanLocalMarketplaces(): Promise<
|
|
|
321
377
|
> {
|
|
322
378
|
const marketplaces = new Map<string, LocalMarketplace>();
|
|
323
379
|
|
|
380
|
+
// A directory is not a marketplace; a REGISTRY ENTRY is. Null means the
|
|
381
|
+
// registry could not be read, and then every directory is kept — see
|
|
382
|
+
// `registeredDirNames`.
|
|
383
|
+
//
|
|
384
|
+
// Claude Code refreshes a marketplace by delete-then-clone and leaves
|
|
385
|
+
// `<name>.bak` behind. An unfiltered scan treated that backup as a real
|
|
386
|
+
// marketplace, which cost the reporter twice: `magus.bak` shadowed the real
|
|
387
|
+
// `magus` in `getAllMarketplaces()` (deduped by repo, first-come) so the real
|
|
388
|
+
// one vanished from the plugin list entirely, and later it became the target
|
|
389
|
+
// of a nonsense label — `kanban@magus … moved → magus.bak`. `claude plugin
|
|
390
|
+
// marketplace list` never showed it, because Claude Code reads the registry.
|
|
391
|
+
const registered = await registeredDirNames();
|
|
392
|
+
|
|
324
393
|
// 1. Scan marketplaces from the standard directory
|
|
325
394
|
if (await fs.pathExists(CLAUDE_PLUGINS_DIR)) {
|
|
326
395
|
try {
|
|
@@ -330,6 +399,7 @@ export async function scanLocalMarketplaces(): Promise<
|
|
|
330
399
|
|
|
331
400
|
for (const entry of entries) {
|
|
332
401
|
if (!entry.isDirectory()) continue;
|
|
402
|
+
if (registered && !registered.has(entry.name)) continue;
|
|
333
403
|
|
|
334
404
|
const marketplacePath = path.join(CLAUDE_PLUGINS_DIR, entry.name);
|
|
335
405
|
const marketplace = await scanSingleMarketplace(
|
package/src/services/manifest.ts
CHANGED
|
@@ -52,6 +52,12 @@ export function migrateToV2(raw: unknown): ProfileManifest {
|
|
|
52
52
|
// Only carry strictVersions when enabled, so a committed manifest that
|
|
53
53
|
// doesn't opt in stays minimal (no spurious `"strictVersions": false`).
|
|
54
54
|
if (obj.strictVersions === true) manifest.strictVersions = true;
|
|
55
|
+
// Carried through even when it names no existing profile: dropping it here
|
|
56
|
+
// would silently "fix" a typo that `validateManifest` is meant to report,
|
|
57
|
+
// and the commands that read it all check the id resolves first.
|
|
58
|
+
if (typeof obj.default === "string" && obj.default !== "") {
|
|
59
|
+
manifest.default = obj.default;
|
|
60
|
+
}
|
|
55
61
|
return manifest;
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -110,6 +116,21 @@ export function validateManifest(
|
|
|
110
116
|
errors.push({ path: "profiles", message: "no profiles defined" });
|
|
111
117
|
}
|
|
112
118
|
|
|
119
|
+
// A `default` naming nothing is worse than none: a fresh clone would fall
|
|
120
|
+
// through to asking, with no sign that the repo tried to answer and got the
|
|
121
|
+
// id wrong. Reported here so it fails the same gate every other manifest
|
|
122
|
+
// mistake does, rather than being discovered as silence.
|
|
123
|
+
if (manifest.default !== undefined) {
|
|
124
|
+
if (typeof manifest.default !== "string") {
|
|
125
|
+
errors.push({ path: "default", message: "must be a profile id" });
|
|
126
|
+
} else if (!ids.includes(manifest.default)) {
|
|
127
|
+
errors.push({
|
|
128
|
+
path: "default",
|
|
129
|
+
message: `no profile "${manifest.default}" — declared: ${ids.join(", ")}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
113
134
|
for (const [id, entry] of Object.entries(manifest.profiles ?? {})) {
|
|
114
135
|
const base = `profiles.${id}`;
|
|
115
136
|
if (!entry || typeof entry !== "object") {
|
|
@@ -36,17 +36,6 @@ export interface RefreshResult {
|
|
|
36
36
|
failed: string[];
|
|
37
37
|
/** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
|
|
38
38
|
skipped: string[];
|
|
39
|
-
/**
|
|
40
|
-
* Skipped specifically because `autoUpdate: false` in known_marketplaces.json.
|
|
41
|
-
*
|
|
42
|
-
* Called out separately from `skipped` because the consequence is invisible
|
|
43
|
-
* and open-ended: the clone is the catalog, so a marketplace opted out here
|
|
44
|
-
* never learns about new plugin versions, and every plugin from it reads as
|
|
45
|
-
* up to date forever. The reporter's `magus` clone sat 11 days behind this
|
|
46
|
-
* way — Claude Code would not refresh it, and this function skipped it too,
|
|
47
|
-
* with nothing printed either time.
|
|
48
|
-
*/
|
|
49
|
-
autoUpdateDisabled: string[];
|
|
50
39
|
}
|
|
51
40
|
|
|
52
41
|
function cloneDir(name: string): string {
|
|
@@ -198,7 +187,6 @@ export async function refreshRegisteredMarketplaces(
|
|
|
198
187
|
const refreshed: string[] = [];
|
|
199
188
|
const failed: string[] = [];
|
|
200
189
|
const skipped: string[] = [];
|
|
201
|
-
const autoUpdateDisabled: string[] = [];
|
|
202
190
|
|
|
203
191
|
const configured = await getConfiguredMarketplaces();
|
|
204
192
|
const skipSet = new Set(skip);
|
|
@@ -211,8 +199,13 @@ export async function refreshRegisteredMarketplaces(
|
|
|
211
199
|
skipped.push(name);
|
|
212
200
|
continue;
|
|
213
201
|
}
|
|
202
|
+
// Claude Code's own refresh policy. Honour it by not pulling, and say
|
|
203
|
+
// nothing: the version answer does not come from this clone.
|
|
204
|
+
// `readCatalogFromRemote` reads `origin/<branch>` with fetch + show, never
|
|
205
|
+
// the working tree — measured, a clone holding dev 7.4.0 with autoUpdate
|
|
206
|
+
// off still reported 7.4.1 available.
|
|
214
207
|
if ((await getMarketplaceAutoUpdate(name)) === false) {
|
|
215
|
-
|
|
208
|
+
skipped.push(name);
|
|
216
209
|
continue;
|
|
217
210
|
}
|
|
218
211
|
eligible.push(name);
|
|
@@ -226,7 +219,6 @@ export async function refreshRegisteredMarketplaces(
|
|
|
226
219
|
refreshed: [],
|
|
227
220
|
failed: [],
|
|
228
221
|
skipped: [...skipped, ...eligible],
|
|
229
|
-
autoUpdateDisabled,
|
|
230
222
|
};
|
|
231
223
|
}
|
|
232
224
|
|
|
@@ -249,5 +241,5 @@ export async function refreshRegisteredMarketplaces(
|
|
|
249
241
|
else skipped.push(name); // absent | skipped
|
|
250
242
|
});
|
|
251
243
|
|
|
252
|
-
return { refreshed, failed, skipped
|
|
244
|
+
return { refreshed, failed, skipped };
|
|
253
245
|
}
|
|
@@ -122,15 +122,6 @@ export interface PluginInfo {
|
|
|
122
122
|
* a reinstall delivers the current content. See content-drift.ts.
|
|
123
123
|
*/
|
|
124
124
|
contentStale?: boolean;
|
|
125
|
-
/**
|
|
126
|
-
* For an orphaned plugin: another configured marketplace that publishes a
|
|
127
|
-
* plugin of the same name — i.e. it did not disappear, it moved.
|
|
128
|
-
*
|
|
129
|
-
* Splitting `magus` into `magus` + `magus-marketing` left video-editing and
|
|
130
|
-
* image-generate installed under a namespace that no longer lists them. Rendered as bare "deprecated", the only offered action
|
|
131
|
-
* was deletion, which silently drops a plugin that is still published.
|
|
132
|
-
*/
|
|
133
|
-
movedTo?: string;
|
|
134
125
|
/**
|
|
135
126
|
* Set when this plugin's installed version changed since magus last
|
|
136
127
|
* rendered it — including updates made by Claude Code or a direct CLI call
|
|
@@ -467,9 +458,6 @@ export async function getAvailablePlugins(
|
|
|
467
458
|
// Try to get plugin info from local marketplace cache (fallback)
|
|
468
459
|
const localMp = localMarketplaces.get(mpName);
|
|
469
460
|
const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
|
|
470
|
-
const movedTo = localPlugin
|
|
471
|
-
? undefined
|
|
472
|
-
: findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
|
|
473
461
|
|
|
474
462
|
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
475
463
|
const description = localPlugin?.description || "Installed plugin";
|
|
@@ -486,7 +474,6 @@ export async function getAvailablePlugins(
|
|
|
486
474
|
installedVersion: installedVersion,
|
|
487
475
|
hasUpdate,
|
|
488
476
|
isOrphaned: true,
|
|
489
|
-
movedTo,
|
|
490
477
|
...scopeStatus,
|
|
491
478
|
});
|
|
492
479
|
}
|
|
@@ -495,24 +482,6 @@ export async function getAvailablePlugins(
|
|
|
495
482
|
return plugins;
|
|
496
483
|
}
|
|
497
484
|
|
|
498
|
-
/**
|
|
499
|
-
* Find another configured marketplace that still publishes this plugin name.
|
|
500
|
-
*
|
|
501
|
-
* Answers "did it disappear, or did it move?" for an orphaned plugin, so the
|
|
502
|
-
* UI can offer migration rather than only deletion.
|
|
503
|
-
*/
|
|
504
|
-
function findPluginInOtherMarketplace(
|
|
505
|
-
pluginName: string,
|
|
506
|
-
currentMarketplace: string,
|
|
507
|
-
localMarketplaces: Map<string, LocalMarketplace>,
|
|
508
|
-
): string | undefined {
|
|
509
|
-
for (const [name, mp] of localMarketplaces) {
|
|
510
|
-
if (name === currentMarketplace) continue;
|
|
511
|
-
if (mp.plugins.some((p) => p.name === pluginName)) return name;
|
|
512
|
-
}
|
|
513
|
-
return undefined;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
485
|
/**
|
|
517
486
|
* Is this plugin one whose files could have moved while its version did not?
|
|
518
487
|
*
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finish a migration: put a profile on disk and make the project run it.
|
|
3
|
+
*
|
|
4
|
+
* Adoption used to end at `writeManifest`, which is the whole reason migrating
|
|
5
|
+
* felt like it did nothing. "Active" is not a field anywhere — it is derived
|
|
6
|
+
* from whether `.claude/settings.json` is a symlink into `_profiles/<id>/`
|
|
7
|
+
* (see `activeProfile`). A manifest that nothing links to leaves the project in
|
|
8
|
+
* exactly the state it was in, so the next command still asks which profile to
|
|
9
|
+
* use, having just been told.
|
|
10
|
+
*
|
|
11
|
+
* The three steps have to happen together and in this order, which is why they
|
|
12
|
+
* live in one function rather than at each call site:
|
|
13
|
+
*
|
|
14
|
+
* 1. materialize — write `_profiles/<id>/`, or activation throws
|
|
15
|
+
* 2. gitignore — before the links exist, or a symlink into generated state
|
|
16
|
+
* lands in `git status` (`.claude/` is committed)
|
|
17
|
+
* 3. activate — point the four links at the profile
|
|
18
|
+
*
|
|
19
|
+
* No network and no installs: `resolveProfile` reads the manifest, and binary
|
|
20
|
+
* resolution is skipped outright because nothing here materializes bins. That
|
|
21
|
+
* matters — this runs inside a prompt the user answered, not inside `install`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
26
|
+
import { ensureGitignoreEntries } from "./conventions-manager.js";
|
|
27
|
+
import { writeManifest } from "./manifest.js";
|
|
28
|
+
import { adoptCurrentProject } from "./profile-adopt.js";
|
|
29
|
+
import { materializeProfile } from "./profile-materializer.js";
|
|
30
|
+
import { resolveAllProfiles, resolveProfile } from "./resolver.js";
|
|
31
|
+
import {
|
|
32
|
+
type DisplacedArtifact,
|
|
33
|
+
PROFILE_GITIGNORE_ENTRIES,
|
|
34
|
+
activateProfile,
|
|
35
|
+
} from "./symlink-manager.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bins are declared by plugins and read from the marketplace cache, one
|
|
39
|
+
* `plugin.json` per plugin. Materialization writes settings, mcp, models and
|
|
40
|
+
* skills — never bins — so resolving them here would be pure latency, and on a
|
|
41
|
+
* machine with a cold cache it is latency inside a prompt.
|
|
42
|
+
*/
|
|
43
|
+
const noBins = async () => [];
|
|
44
|
+
|
|
45
|
+
export interface ActivationResult {
|
|
46
|
+
/** The profile now live. */
|
|
47
|
+
profileId: string;
|
|
48
|
+
/** Hand-written artifacts moved aside to make room for the symlinks. */
|
|
49
|
+
displaced: DisplacedArtifact[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Announce one displaced artifact, in project-relative terms.
|
|
54
|
+
*
|
|
55
|
+
* Shared by every command that activates, because a hand-written file moving is
|
|
56
|
+
* the same event whichever command caused it, and the one that is easiest to
|
|
57
|
+
* leave out: `activateProfile` returns the list, and a caller that discards the
|
|
58
|
+
* return value still compiles. `magus install` and `magus profile switch` both
|
|
59
|
+
* did exactly that.
|
|
60
|
+
*
|
|
61
|
+
* Paths are relative because absolute ones wrap over two terminal lines each,
|
|
62
|
+
* and this is the notice a user must not skim.
|
|
63
|
+
*/
|
|
64
|
+
export function reportDisplaced(
|
|
65
|
+
projectPath: string,
|
|
66
|
+
): (artifact: DisplacedArtifact) => void {
|
|
67
|
+
const rel = (p: string) => path.relative(projectPath, p);
|
|
68
|
+
return ({ link, savedTo }) =>
|
|
69
|
+
console.log(` ${rel(link)} → kept at ${rel(savedTo)}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Materialize `profileId` and make it the active profile.
|
|
74
|
+
*
|
|
75
|
+
* The union of plugin ids across EVERY profile is passed to materialization, so
|
|
76
|
+
* the profile writes an explicit `false` for plugins other profiles own. Without
|
|
77
|
+
* it an omitted id falls through to user scope and stays enabled, which would
|
|
78
|
+
* make the freshly adopted profile additive rather than a description of this
|
|
79
|
+
* project — the exact drift adoption was run to end.
|
|
80
|
+
*/
|
|
81
|
+
export async function materializeAndActivate(
|
|
82
|
+
manifest: ProfileManifest,
|
|
83
|
+
profileId: string,
|
|
84
|
+
projectPath: string,
|
|
85
|
+
/** Called as each artifact moves; defaults to printing it. */
|
|
86
|
+
onDisplace: (artifact: DisplacedArtifact) => void = reportDisplaced(
|
|
87
|
+
projectPath,
|
|
88
|
+
),
|
|
89
|
+
): Promise<ActivationResult> {
|
|
90
|
+
const closure = await resolveProfile(manifest, profileId, {
|
|
91
|
+
binResolver: noBins,
|
|
92
|
+
});
|
|
93
|
+
const union = await resolveAllProfiles(manifest, { binResolver: noBins });
|
|
94
|
+
|
|
95
|
+
await materializeProfile(
|
|
96
|
+
profileId,
|
|
97
|
+
closure,
|
|
98
|
+
projectPath,
|
|
99
|
+
Object.keys(union.plugins),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await ensureGitignoreEntries(
|
|
103
|
+
path.join(projectPath, ".gitignore"),
|
|
104
|
+
PROFILE_GITIGNORE_ENTRIES,
|
|
105
|
+
"# magus: generated profile build output + active-profile symlinks",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const displaced = await activateProfile(profileId, projectPath, onDisplace);
|
|
109
|
+
return { profileId, displaced };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The two ways a project can be put onto a profile. */
|
|
113
|
+
export type ProfileChoice =
|
|
114
|
+
| { kind: "adopt"; id: string }
|
|
115
|
+
| { kind: "activate"; id: string };
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Carry out the user's choice, whichever surface asked.
|
|
119
|
+
*
|
|
120
|
+
* One implementation because "migrate" is two writes that must not come apart —
|
|
121
|
+
* write the manifest, then link the project to it — and a second copy of that
|
|
122
|
+
* pairing in the other surface is how one of them ends up writing the manifest
|
|
123
|
+
* and stopping, which is the defect this whole area was fixed for.
|
|
124
|
+
*/
|
|
125
|
+
export async function applyProfileChoice(
|
|
126
|
+
choice: ProfileChoice,
|
|
127
|
+
manifest: ProfileManifest,
|
|
128
|
+
projectPath: string,
|
|
129
|
+
onDisplace?: (artifact: DisplacedArtifact) => void,
|
|
130
|
+
): Promise<ActivationResult> {
|
|
131
|
+
if (choice.kind === "activate") {
|
|
132
|
+
return materializeAndActivate(manifest, choice.id, projectPath, onDisplace);
|
|
133
|
+
}
|
|
134
|
+
const result = await adoptCurrentProject(
|
|
135
|
+
projectPath,
|
|
136
|
+
undefined,
|
|
137
|
+
manifest,
|
|
138
|
+
choice.id,
|
|
139
|
+
);
|
|
140
|
+
await writeManifest(result.manifest, projectPath);
|
|
141
|
+
return materializeAndActivate(
|
|
142
|
+
result.manifest,
|
|
143
|
+
choice.id,
|
|
144
|
+
projectPath,
|
|
145
|
+
onDisplace,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -30,6 +30,21 @@ import { withDerivedMarketplaces } from "./resolver.js";
|
|
|
30
30
|
/** The profile id used when adopting a project that has no manifest. */
|
|
31
31
|
export const ADOPTED_PROFILE_ID = "default";
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* First free profile id, so adopting never overwrites an existing profile.
|
|
35
|
+
*
|
|
36
|
+
* Lives here rather than at a call site because both surfaces adopt now — the
|
|
37
|
+
* CLI prompt and the TUI's startup offer — and two copies of "which id is
|
|
38
|
+
* free?" is two chances to overwrite somebody's profile.
|
|
39
|
+
*/
|
|
40
|
+
export function freeProfileId(manifest: ProfileManifest): string {
|
|
41
|
+
if (!manifest.profiles[ADOPTED_PROFILE_ID]) return ADOPTED_PROFILE_ID;
|
|
42
|
+
for (let n = 2; ; n++) {
|
|
43
|
+
const id = `${ADOPTED_PROFILE_ID}-${n}`;
|
|
44
|
+
if (!manifest.profiles[id]) return id;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
export interface AdoptInput {
|
|
34
49
|
/** Union of enabled plugins across every scope: id -> enabled. */
|
|
35
50
|
enabledPlugins: Record<string, boolean>;
|
|
@@ -147,7 +162,7 @@ export type AdoptSource = "project" | "user";
|
|
|
147
162
|
* everything globally would otherwise have nothing to adopt. The caller is told
|
|
148
163
|
* which source was used and says so, so the choice is never invisible.
|
|
149
164
|
*/
|
|
150
|
-
async function collectEnabledPlugins(
|
|
165
|
+
export async function collectEnabledPlugins(
|
|
151
166
|
projectPath: string,
|
|
152
167
|
): Promise<{ plugins: Record<string, boolean>; source: AdoptSource }> {
|
|
153
168
|
const project = await getEnabledPlugins(projectPath).catch(() => ({}));
|
|
@@ -165,6 +180,19 @@ async function collectEnabledPlugins(
|
|
|
165
180
|
export async function adoptCurrentProject(
|
|
166
181
|
projectPath: string,
|
|
167
182
|
name?: string,
|
|
183
|
+
/**
|
|
184
|
+
* Manifest to add the adopted profile to. Omitted, adoption starts from an
|
|
185
|
+
* empty manifest — correct for a repo that has none.
|
|
186
|
+
*
|
|
187
|
+
* This parameter is what makes adoption reachable twice. Without it the
|
|
188
|
+
* function always returned `emptyManifest()` plus one profile, so calling
|
|
189
|
+
* it on a repo that HAD profiles would have deleted them, and the zero-
|
|
190
|
+
* profile gate in `ensureManifest` was the only thing preventing that.
|
|
191
|
+
* Existing profiles are preserved untouched; only `id` is written.
|
|
192
|
+
*/
|
|
193
|
+
into?: ProfileManifest,
|
|
194
|
+
/** Profile id to write. Defaults to the adopted-profile id. */
|
|
195
|
+
id: string = ADOPTED_PROFILE_ID,
|
|
168
196
|
): Promise<AdoptResult> {
|
|
169
197
|
const { plugins: enabledPlugins, source } =
|
|
170
198
|
await collectEnabledPlugins(projectPath);
|
|
@@ -181,8 +209,26 @@ export async function adoptCurrentProject(
|
|
|
181
209
|
name,
|
|
182
210
|
});
|
|
183
211
|
|
|
184
|
-
|
|
185
|
-
|
|
212
|
+
// Copy rather than mutate the caller's manifest: this function does not
|
|
213
|
+
// write, and a caller that declines the plan must be left with the file it
|
|
214
|
+
// had, not a version already carrying the profile it refused.
|
|
215
|
+
const manifest: ProfileManifest = into
|
|
216
|
+
? { ...into, profiles: { ...into.profiles } }
|
|
217
|
+
: emptyManifest();
|
|
218
|
+
manifest.profiles[id] = entry;
|
|
219
|
+
|
|
220
|
+
// Record which profile the project wants, but ONLY when nothing has said yet.
|
|
221
|
+
//
|
|
222
|
+
// Writing it on a first adoption is the point: the manifest is committed, and
|
|
223
|
+
// a teammate who clones it should get this profile without being asked. It is
|
|
224
|
+
// the answer the repo now has.
|
|
225
|
+
//
|
|
226
|
+
// Overwriting an existing `default` would be different — one developer saving
|
|
227
|
+
// their local state would republish the team's committed choice as whatever
|
|
228
|
+
// they happened to have on. The new profile still becomes active for them,
|
|
229
|
+
// because activation is per-developer; the declared intent is not theirs to
|
|
230
|
+
// change by accident.
|
|
231
|
+
if (manifest.default === undefined) manifest.default = id;
|
|
186
232
|
|
|
187
233
|
const skipped = Object.entries(enabledPlugins)
|
|
188
234
|
.filter(([id, on]) => on && !id.includes("@"))
|
|
@@ -190,7 +236,7 @@ export async function adoptCurrentProject(
|
|
|
190
236
|
|
|
191
237
|
return {
|
|
192
238
|
manifest,
|
|
193
|
-
profileId:
|
|
239
|
+
profileId: id,
|
|
194
240
|
entry,
|
|
195
241
|
source,
|
|
196
242
|
counts: {
|