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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this project usable, and if not, what has to happen first.
|
|
3
|
+
*
|
|
4
|
+
* ## One source of truth, not two
|
|
5
|
+
*
|
|
6
|
+
* A profiled project's `.claude/settings.json` is a SYMLINK into
|
|
7
|
+
* `_profiles/<id>/`, and that file is generated from the manifest. So the
|
|
8
|
+
* manifest is the truth and `enabledPlugins` is its output.
|
|
9
|
+
*
|
|
10
|
+
* An earlier version of this file compared the two — the manifest against the
|
|
11
|
+
* plugins `enabledPlugins` lists — and offered to create a new profile when
|
|
12
|
+
* they disagreed. That is coherent only in the profileless world, where
|
|
13
|
+
* `settings.json` is a real file somebody wrote. Once a profile is active it
|
|
14
|
+
* compares the manifest against its own generated output, and "they disagree"
|
|
15
|
+
* cannot mean what the offer claimed: the honest answer is to re-materialize
|
|
16
|
+
* the profile, never to invent a third one describing the corrupted output.
|
|
17
|
+
*
|
|
18
|
+
* Worse, it meant magus behaved as though both worlds were live at once. So the
|
|
19
|
+
* comparison is gone. Nothing here reads `enabledPlugins`, and nothing compares
|
|
20
|
+
* a set of declared plugins to a set of installed ones — they are different
|
|
21
|
+
* kinds of thing and a comparison between them cannot be acted on.
|
|
22
|
+
*
|
|
23
|
+
* ## What is left is a three-state question
|
|
24
|
+
*
|
|
25
|
+
* ok a profile is active; the manifest is in effect; ask nothing
|
|
26
|
+
* unmigrated no manifest — the project is not using profiles at all
|
|
27
|
+
* inactive a manifest with profiles, none of them linked into place
|
|
28
|
+
*
|
|
29
|
+
* `unmigrated` and `inactive` are both unusable, not merely untidy: with no
|
|
30
|
+
* active profile there is no profile state to read and nothing to write updates
|
|
31
|
+
* into, so magus cannot do its job. The caller must not offer to continue.
|
|
32
|
+
*
|
|
33
|
+
* Detection only — nothing here writes. That is what makes it callable from the
|
|
34
|
+
* TUI's startup effect, and testable without a renderer.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
38
|
+
// Type-only, so it is erased: this module stays free of the UI and of React,
|
|
39
|
+
// which is what lets `magus hook` and the CLI import it. The shape of a
|
|
40
|
+
// selectable option is presentation-neutral data, and both surfaces render the
|
|
41
|
+
// same list from these builders rather than each composing its own.
|
|
42
|
+
import type { SelectOption } from "../ui/state/types.js";
|
|
43
|
+
import { readManifest } from "./manifest.js";
|
|
44
|
+
import type { ProfileChoice } from "./profile-activate.js";
|
|
45
|
+
import { freeProfileId } from "./profile-adopt.js";
|
|
46
|
+
import { activeProfile, displaceableArtifacts } from "./symlink-manager.js";
|
|
47
|
+
|
|
48
|
+
/** Whether the project can be worked on, and if not, why not. */
|
|
49
|
+
export type ProfileStartupState = "ok" | "unmigrated" | "inactive";
|
|
50
|
+
|
|
51
|
+
/** A profile the user could activate. */
|
|
52
|
+
export interface ActivatableProfile {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
/**
|
|
56
|
+
* How many plugins it declares.
|
|
57
|
+
*
|
|
58
|
+
* A property of the profile, read from the manifest — NOT a comparison
|
|
59
|
+
* against what is installed. It is here to tell two rows apart, nothing more.
|
|
60
|
+
*/
|
|
61
|
+
pluginCount: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ProfileStartupNeed {
|
|
65
|
+
state: ProfileStartupState;
|
|
66
|
+
/**
|
|
67
|
+
* Id to create if the user wants what is turned on captured as a profile.
|
|
68
|
+
* Null when a profile is already active. Never an id already in use.
|
|
69
|
+
*/
|
|
70
|
+
adopt: string | null;
|
|
71
|
+
/** Profiles that could be activated. Empty unless the state is `inactive`. */
|
|
72
|
+
activate: ActivatableProfile[];
|
|
73
|
+
/**
|
|
74
|
+
* The profile this project declares it wants, if the manifest names one that
|
|
75
|
+
* exists. Used to preselect the row so Enter does the intended thing.
|
|
76
|
+
*/
|
|
77
|
+
declaredDefault: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* Activate `declaredDefault` without asking.
|
|
80
|
+
*
|
|
81
|
+
* True only when the repo answered the question AND activating would replace
|
|
82
|
+
* nothing a human put there — a fresh clone. Asking there is asking someone
|
|
83
|
+
* to re-decide something their team already decided, with nothing at stake.
|
|
84
|
+
*
|
|
85
|
+
* False the moment there is anything to lose, however clear the declared
|
|
86
|
+
* default is: replacing someone's working files is never automatic.
|
|
87
|
+
*/
|
|
88
|
+
autoActivate: boolean;
|
|
89
|
+
/** The manifest as read, so the caller does not read it a second time. */
|
|
90
|
+
manifest: ProfileManifest;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True when the project is usable and there is nothing to ask. */
|
|
94
|
+
export function needsNothing(need: ProfileStartupNeed): boolean {
|
|
95
|
+
return need.state === "ok";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Inspect the project and decide what, if anything, must happen first. */
|
|
99
|
+
export async function detectProfileStartupNeed(
|
|
100
|
+
projectPath: string,
|
|
101
|
+
): Promise<ProfileStartupNeed> {
|
|
102
|
+
const manifest = await readManifest(projectPath);
|
|
103
|
+
const ids = Object.keys(manifest.profiles);
|
|
104
|
+
|
|
105
|
+
if (ids.length === 0) {
|
|
106
|
+
return {
|
|
107
|
+
state: "unmigrated",
|
|
108
|
+
adopt: freeProfileId(manifest),
|
|
109
|
+
activate: [],
|
|
110
|
+
declaredDefault: null,
|
|
111
|
+
autoActivate: false,
|
|
112
|
+
manifest,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The ONE question that decides whether the manifest is in effect. Not a
|
|
117
|
+
// comparison, not a heuristic: either settings.json is a link into a
|
|
118
|
+
// profile or it is not.
|
|
119
|
+
if ((await activeProfile(projectPath)) !== null) {
|
|
120
|
+
return {
|
|
121
|
+
state: "ok",
|
|
122
|
+
adopt: null,
|
|
123
|
+
activate: [],
|
|
124
|
+
declaredDefault: null,
|
|
125
|
+
autoActivate: false,
|
|
126
|
+
manifest,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validated, not trusted: a `default` naming a profile that was renamed or
|
|
131
|
+
// removed must fall back to asking rather than throw somewhere downstream.
|
|
132
|
+
// `validateManifest` reports the typo; this keeps magus usable meanwhile.
|
|
133
|
+
const declaredDefault =
|
|
134
|
+
manifest.default !== undefined && ids.includes(manifest.default)
|
|
135
|
+
? manifest.default
|
|
136
|
+
: null;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
state: "inactive",
|
|
140
|
+
declaredDefault,
|
|
141
|
+
// Only when the repo answered AND there is nothing of the user's to
|
|
142
|
+
// replace. Both halves matter: without the first there is no answer to
|
|
143
|
+
// apply, and without the second applying it silently destroys work.
|
|
144
|
+
autoActivate:
|
|
145
|
+
declaredDefault !== null &&
|
|
146
|
+
(await displaceableArtifacts(projectPath)).length === 0,
|
|
147
|
+
// Still offered, because a manifest whose profiles all describe something
|
|
148
|
+
// this project no longer is would otherwise be a dead end — activating a
|
|
149
|
+
// wrong profile is the only way out. Offering it costs a row; NOT offering
|
|
150
|
+
// it rebuilds the trap. Which one to take is the user's call to make, and
|
|
151
|
+
// magus can no longer form an opinion on it without the comparison that
|
|
152
|
+
// used to be here.
|
|
153
|
+
adopt: freeProfileId(manifest),
|
|
154
|
+
activate: ids.map((id) => ({
|
|
155
|
+
id,
|
|
156
|
+
name: manifest.profiles[id]?.name ?? id,
|
|
157
|
+
pluginCount: Object.keys(manifest.profiles[id]?.plugins ?? {}).length,
|
|
158
|
+
})),
|
|
159
|
+
manifest,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── What to offer, as data both surfaces render ─────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** Option values are parsed back, so they are data, not prose. */
|
|
166
|
+
export const ADOPT_VALUE = "adopt";
|
|
167
|
+
export const QUIT_VALUE = "quit";
|
|
168
|
+
export const ACTIVATE_PREFIX = "activate:";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Title and message for the state we are in.
|
|
172
|
+
*
|
|
173
|
+
* Written about what the user has, never about magus's internals. Two rewrites
|
|
174
|
+
* got here, and both failures are worth keeping in mind.
|
|
175
|
+
*
|
|
176
|
+
* First it explained the machinery: "the manifest is not in effect", "there is
|
|
177
|
+
* no profile state to read and nowhere to write updates". Accurate, and an
|
|
178
|
+
* answer to a question nobody standing in front of this modal is asking.
|
|
179
|
+
*
|
|
180
|
+
* Then it traded jargon for abstraction: "the setup you are running belongs to
|
|
181
|
+
* no profile". "Setup" names nothing the reader can point at, and "belongs to"
|
|
182
|
+
* describes a relationship rather than a consequence. So the copy now names the
|
|
183
|
+
* concrete things — the plugins and skills turned on for this project — and
|
|
184
|
+
* says what happens to them: they will be overwritten.
|
|
185
|
+
*
|
|
186
|
+
* That consequence is the whole reason the save option exists. A screen offering
|
|
187
|
+
* "save" and "switch" without saying they are alternatives asks the reader to
|
|
188
|
+
* guess which one keeps their work.
|
|
189
|
+
*
|
|
190
|
+
* The `unmigrated` copy must NOT carry that warning: adopting there captures
|
|
191
|
+
* what is already present, so the reassurance is the point.
|
|
192
|
+
*
|
|
193
|
+
* Neither mentions the backup, deliberately. A sentence promising "nothing is
|
|
194
|
+
* deleted — the old files are moved to a backup" raises a question the reader
|
|
195
|
+
* did not have (which files? what was at risk?) and implies data loss, when what
|
|
196
|
+
* changes is settings. Where the displaced files went belongs in the outcome
|
|
197
|
+
* modal AFTER the switch, where it is something to act on rather than a worry to
|
|
198
|
+
* plant beforehand.
|
|
199
|
+
*
|
|
200
|
+
* No file paths in these strings, deliberately. The TUI modal soft-wraps its
|
|
201
|
+
* message at whatever width the terminal gives it, and OpenTUI treats `.` as a
|
|
202
|
+
* break opportunity — MEASURED on screen, `.claude/settings.json` wrapped as
|
|
203
|
+
* `.` then `claude/settings.json` on the next line. A path split across a line
|
|
204
|
+
* break reads as two different paths, neither of them real.
|
|
205
|
+
*/
|
|
206
|
+
export function describeNeed(need: ProfileStartupNeed): {
|
|
207
|
+
title: string;
|
|
208
|
+
message: string;
|
|
209
|
+
} {
|
|
210
|
+
if (need.state === "unmigrated") {
|
|
211
|
+
return {
|
|
212
|
+
title: "magus uses profiles",
|
|
213
|
+
message:
|
|
214
|
+
"A profile is a saved set of plugins, skills and MCP servers for one project. Commit it, and everyone on the team gets the same ones.\n\nThis project has no profile yet. Saving what it uses now makes the first one, and nothing about what you run changes.",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
title: "No profile selected",
|
|
220
|
+
message:
|
|
221
|
+
"A profile is a saved set of plugins, skills and MCP servers for one project.\n\nThe plugins and skills turned on here are not saved in any profile. Switch to one below and they will be overwritten.\n\nTo keep them, save them as a new profile first.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The options for a need, in the order they should be offered.
|
|
227
|
+
*
|
|
228
|
+
* There is no "Not now". Without an active profile magus has no profile state
|
|
229
|
+
* to read and nowhere to write updates, so "carry on anyway" is not something
|
|
230
|
+
* it can offer — both surfaces used to have that escape, the TUI as a list row
|
|
231
|
+
* and the CLI as "using X for this run only", and both let the user into a
|
|
232
|
+
* magus that could not do its job.
|
|
233
|
+
*
|
|
234
|
+
* "Quit magus" is last instead. A question that cannot be answered with "no"
|
|
235
|
+
* still has to be answerable with "not in this app", and the way out has to be
|
|
236
|
+
* visible rather than a keystroke the user has to guess.
|
|
237
|
+
*/
|
|
238
|
+
export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
|
|
239
|
+
const options: SelectOption[] = [];
|
|
240
|
+
|
|
241
|
+
if (need.adopt !== null) {
|
|
242
|
+
options.push({
|
|
243
|
+
// Says what it PRESERVES, not what it creates. "Create <id> from this
|
|
244
|
+
// project" described the mechanism; the reason to pick this row is that
|
|
245
|
+
// it is the only one that keeps what is turned on right now.
|
|
246
|
+
//
|
|
247
|
+
// Kept short on purpose: this is the longest label in the list, and the
|
|
248
|
+
// meta column is measured against the longest one. "…and use it" made it
|
|
249
|
+
// 41 cells and pushed every plugin count onto a line of its own.
|
|
250
|
+
label: `Save what you have as "${need.adopt}"`,
|
|
251
|
+
value: ADOPT_VALUE,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const profile of need.activate) {
|
|
256
|
+
options.push({
|
|
257
|
+
label: `Activate ${profile.name}`,
|
|
258
|
+
value: `${ACTIVATE_PREFIX}${profile.id}`,
|
|
259
|
+
// The profile's own plugin count, to tell two rows apart. There used to
|
|
260
|
+
// be a "matches now" badge here, marking the profile that declared
|
|
261
|
+
// exactly what `enabledPlugins` had on. It is gone with the comparison
|
|
262
|
+
// that produced it: it asserted that the installed set was a legitimate
|
|
263
|
+
// thing to judge a profile against, which is the two-worlds confusion
|
|
264
|
+
// this change removes.
|
|
265
|
+
meta: `${profile.pluginCount} plugin${profile.pluginCount === 1 ? "" : "s"}`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
options.push({ label: "Quit magus", value: QUIT_VALUE });
|
|
270
|
+
return options;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Which row to start on.
|
|
275
|
+
*
|
|
276
|
+
* The profile the repo declares, when it names one — so a developer who does
|
|
277
|
+
* have local work to weigh still gets their team's answer under the cursor, and
|
|
278
|
+
* Enter does the intended thing. Falls back to the first row, which is the save
|
|
279
|
+
* option: the safe default when nobody has said otherwise, because it is the
|
|
280
|
+
* only one that keeps what is turned on.
|
|
281
|
+
*/
|
|
282
|
+
export function defaultOptionIndex(need: ProfileStartupNeed): number {
|
|
283
|
+
if (need.declaredDefault === null) return 0;
|
|
284
|
+
const at = buildProfileOptions(need).findIndex(
|
|
285
|
+
(o) => o.value === `${ACTIVATE_PREFIX}${need.declaredDefault}`,
|
|
286
|
+
);
|
|
287
|
+
return at === -1 ? 0 : at;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Turn a chosen option value into the action it means.
|
|
292
|
+
*
|
|
293
|
+
* Returns null for "quit" and for anything unrecognised — including `adopt`
|
|
294
|
+
* when there is nothing to adopt, so a stale value cannot reach the adopt path
|
|
295
|
+
* with no id.
|
|
296
|
+
*/
|
|
297
|
+
export function parseProfileChoice(
|
|
298
|
+
value: string,
|
|
299
|
+
need: ProfileStartupNeed,
|
|
300
|
+
): ProfileChoice | null {
|
|
301
|
+
if (value === ADOPT_VALUE && need.adopt !== null) {
|
|
302
|
+
return { kind: "adopt", id: need.adopt };
|
|
303
|
+
}
|
|
304
|
+
if (value.startsWith(ACTIVATE_PREFIX)) {
|
|
305
|
+
const id = value.slice(ACTIVATE_PREFIX.length);
|
|
306
|
+
return need.activate.some((p) => p.id === id)
|
|
307
|
+
? { kind: "activate", id }
|
|
308
|
+
: null;
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
package/src/services/profiles.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "fs-extra";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ProfileEntry, ProfileManifest } from "../types/index.js";
|
|
5
|
+
import { activeProfile } from "./symlink-manager.js";
|
|
5
6
|
|
|
6
7
|
const PROFILES_FILE = "profiles.json";
|
|
7
8
|
|
|
@@ -21,7 +22,7 @@ export function getProjectProfilesPath(projectPath?: string): string {
|
|
|
21
22
|
export async function readProfiles(
|
|
22
23
|
scope: "user" | "project",
|
|
23
24
|
projectPath?: string,
|
|
24
|
-
): Promise<
|
|
25
|
+
): Promise<ProfileManifest> {
|
|
25
26
|
const filePath =
|
|
26
27
|
scope === "user"
|
|
27
28
|
? getUserProfilesPath()
|
|
@@ -29,17 +30,23 @@ export async function readProfiles(
|
|
|
29
30
|
try {
|
|
30
31
|
if (await fs.pathExists(filePath)) {
|
|
31
32
|
const data = await fs.readJson(filePath);
|
|
32
|
-
|
|
33
|
+
// A v1 file differs only in what it omits, so it reads as a v2 entry
|
|
34
|
+
// with absent optional fields. Nothing here converts: `version: 2` is
|
|
35
|
+
// what the next write records, and every consumer already treats
|
|
36
|
+
// `createdAt`, `updatedAt` and a string plugin pin as optional.
|
|
37
|
+
if (data.version && data.profiles) {
|
|
38
|
+
return { ...data, version: 2 } as ProfileManifest;
|
|
39
|
+
}
|
|
33
40
|
}
|
|
34
41
|
} catch {
|
|
35
42
|
// fall through to default
|
|
36
43
|
}
|
|
37
|
-
return { version:
|
|
44
|
+
return { version: 2, profiles: {} };
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
async function writeProfiles(
|
|
41
48
|
scope: "user" | "project",
|
|
42
|
-
data:
|
|
49
|
+
data: ProfileManifest,
|
|
43
50
|
projectPath?: string,
|
|
44
51
|
): Promise<void> {
|
|
45
52
|
const filePath =
|
|
@@ -56,21 +63,33 @@ async function writeProfiles(
|
|
|
56
63
|
export async function listProfiles(
|
|
57
64
|
projectPath?: string,
|
|
58
65
|
): Promise<ProfileEntry[]> {
|
|
59
|
-
|
|
66
|
+
// The active profile is read alongside the files rather than by the caller:
|
|
67
|
+
// every surface that lists profiles wants to mark the live one, and a flag
|
|
68
|
+
// each of them derives itself is a flag some of them forget.
|
|
69
|
+
const [user, project, active] = await Promise.all([
|
|
60
70
|
readProfiles("user"),
|
|
61
71
|
readProfiles("project", projectPath),
|
|
72
|
+
activeProfile(projectPath),
|
|
62
73
|
]);
|
|
63
74
|
const entries: ProfileEntry[] = [];
|
|
64
75
|
for (const [id, p] of Object.entries(user.profiles)) {
|
|
65
76
|
entries.push({ id, scope: "user", ...p });
|
|
66
77
|
}
|
|
67
78
|
for (const [id, p] of Object.entries(project.profiles)) {
|
|
68
|
-
entries.push({ id, scope: "project", ...p });
|
|
79
|
+
entries.push({ id, scope: "project", ...p, active: id === active });
|
|
69
80
|
}
|
|
70
|
-
// Sort: user profiles first, then project, each by updatedAt desc
|
|
81
|
+
// Sort: user profiles first, then project, each by updatedAt desc.
|
|
82
|
+
//
|
|
83
|
+
// `updatedAt` is optional — a hand-written or CLI-authored profile carries
|
|
84
|
+
// no timestamp, and dereferencing it here is what made this function throw
|
|
85
|
+
// on every v2 file. An undated profile sorts last within its scope, then by
|
|
86
|
+
// id so the order is stable rather than dependent on key order.
|
|
71
87
|
entries.sort((a, b) => {
|
|
72
88
|
if (a.scope !== b.scope) return a.scope === "user" ? -1 : 1;
|
|
73
|
-
|
|
89
|
+
const at = a.updatedAt ?? "";
|
|
90
|
+
const bt = b.updatedAt ?? "";
|
|
91
|
+
if (at !== bt) return bt.localeCompare(at);
|
|
92
|
+
return a.id.localeCompare(b.id);
|
|
74
93
|
});
|
|
75
94
|
return entries;
|
|
76
95
|
}
|
|
@@ -98,6 +117,25 @@ export async function saveProfile(
|
|
|
98
117
|
return id;
|
|
99
118
|
}
|
|
100
119
|
|
|
120
|
+
/**
|
|
121
|
+
* A profile's plugin map as Claude Code's `enabledPlugins` needs it.
|
|
122
|
+
*
|
|
123
|
+
* A profile pins a plugin to a version ("1.2.0"), to "latest", or to `true`.
|
|
124
|
+
* Claude Code's `enabledPlugins` takes booleans and nothing else, so spreading
|
|
125
|
+
* a profile straight in writes `"latest"` where `true` belongs and the plugin
|
|
126
|
+
* silently stops loading. Only an explicit `false` means disabled; every pin
|
|
127
|
+
* names a plugin the profile wants on.
|
|
128
|
+
*/
|
|
129
|
+
export function toEnabledPlugins(
|
|
130
|
+
plugins: Record<string, string | boolean> | undefined,
|
|
131
|
+
): Record<string, boolean> {
|
|
132
|
+
const out: Record<string, boolean> = {};
|
|
133
|
+
for (const [id, pin] of Object.entries(plugins ?? {})) {
|
|
134
|
+
out[id] = pin !== false;
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
101
139
|
/**
|
|
102
140
|
* Apply a profile: replaces enabledPlugins in the target settings scope.
|
|
103
141
|
* targetScope is where the settings will be written, independent of where
|
|
@@ -123,12 +161,12 @@ export async function applyProfile(
|
|
|
123
161
|
|
|
124
162
|
if (targetScope === "user") {
|
|
125
163
|
const settings = await readGlobalSettings();
|
|
126
|
-
settings.enabledPlugins =
|
|
164
|
+
settings.enabledPlugins = toEnabledPlugins(profile.plugins);
|
|
127
165
|
await writeGlobalSettings(settings);
|
|
128
166
|
} else {
|
|
129
167
|
// project or local both write to project settings.json
|
|
130
168
|
const settings = await readSettings(projectPath);
|
|
131
|
-
settings.enabledPlugins =
|
|
169
|
+
settings.enabledPlugins = toEnabledPlugins(profile.plugins);
|
|
132
170
|
await writeSettings(settings, projectPath);
|
|
133
171
|
}
|
|
134
172
|
}
|
package/src/services/resolver.ts
CHANGED
|
@@ -19,7 +19,9 @@ import type {
|
|
|
19
19
|
ResolvedBin,
|
|
20
20
|
ResolvedClosure,
|
|
21
21
|
} from "../types/index.js";
|
|
22
|
+
import { readManifest } from "./manifest.js";
|
|
22
23
|
import { resolvePluginBinRequirements } from "./plugin-requires.js";
|
|
24
|
+
import { activeProfile } from "./symlink-manager.js";
|
|
23
25
|
|
|
24
26
|
export interface ResolveOptions {
|
|
25
27
|
/**
|
|
@@ -190,6 +192,37 @@ export function withDerivedMarketplaces(
|
|
|
190
192
|
return out;
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The active profile's plugin pins, or null when there is nothing to read.
|
|
197
|
+
*
|
|
198
|
+
* A convenience for callers that want the manifest's opinion but must never be
|
|
199
|
+
* blocked by it — the TUI above all. Every failure path answers null: no
|
|
200
|
+
* manifest file, no profiles in it, no active profile and more than one to
|
|
201
|
+
* choose from, an unresolvable profile. A project without a manifest is the
|
|
202
|
+
* normal case, not an error, and a broken one must not stop the plugin list
|
|
203
|
+
* from rendering.
|
|
204
|
+
*
|
|
205
|
+
* `magus update` deliberately does NOT use this: it needs to fail loudly
|
|
206
|
+
* when the profile is ambiguous, and to name the profile it resolved.
|
|
207
|
+
*/
|
|
208
|
+
export async function activeProfilePins(
|
|
209
|
+
projectPath?: string,
|
|
210
|
+
): Promise<Record<string, string> | null> {
|
|
211
|
+
try {
|
|
212
|
+
const manifest = await readManifest(projectPath);
|
|
213
|
+
const ids = Object.keys(manifest.profiles);
|
|
214
|
+
if (ids.length === 0) return null;
|
|
215
|
+
const id =
|
|
216
|
+
(await activeProfile(projectPath)) ??
|
|
217
|
+
(ids.length === 1 ? ids[0] : undefined);
|
|
218
|
+
if (!id) return null;
|
|
219
|
+
const closure = await resolveProfile(manifest, id);
|
|
220
|
+
return closure.plugins;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
193
226
|
export async function resolveProfile(
|
|
194
227
|
manifest: ProfileManifest,
|
|
195
228
|
profileId: string,
|