magus-cli 7.1.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__/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/cli/ansi.ts +39 -0
- 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/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +1 -0
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/manifest.ts +21 -0
- 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/symlink-manager.ts +184 -30
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -0
- 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/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +54 -0
|
@@ -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: {
|
|
@@ -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
|
}
|