magus-cli 7.5.3 → 7.6.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-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -947,7 +947,7 @@ export async function absorbOrphanedProfile(
|
|
|
947
947
|
);
|
|
948
948
|
if (!base && changes.length > 0) {
|
|
949
949
|
result.lines.push(
|
|
950
|
-
`There is no record of what "${orphan}" contained, so
|
|
950
|
+
`There is no record of what "${orphan}" contained, so every plugin and setting here that "${target}" lacked was kept`,
|
|
951
951
|
);
|
|
952
952
|
}
|
|
953
953
|
result.lines.push(...result.changes.map((c) => describeChange(c, target)));
|
|
@@ -1022,7 +1022,7 @@ export async function absorbOlderLayout(
|
|
|
1022
1022
|
: new SettingsParseError(livePath(key, projectPath), error);
|
|
1023
1023
|
result.unreadable.push(parseError);
|
|
1024
1024
|
result.problems.push(
|
|
1025
|
-
`${rel(projectPath, key)} cannot be read (${parseError.detail}) — fix it by hand; magus
|
|
1025
|
+
`${rel(projectPath, key)} cannot be read (${parseError.detail}) — fix it by hand; magus did not update the old profile format`,
|
|
1026
1026
|
);
|
|
1027
1027
|
continue;
|
|
1028
1028
|
}
|
|
@@ -1056,8 +1056,8 @@ export async function absorbOlderLayout(
|
|
|
1056
1056
|
result.changes.push(...changes);
|
|
1057
1057
|
result.lines.push(
|
|
1058
1058
|
exact
|
|
1059
|
-
? `Changes Claude Code made
|
|
1060
|
-
: `Profile "${named ?? "?"}" no longer exists in profiles.json — its unsaved changes
|
|
1059
|
+
? `Changes Claude Code made were added to profile "${into}"`
|
|
1060
|
+
: `Profile "${named ?? "?"}" no longer exists in profiles.json — its unsaved changes were added to profile "${into}"`,
|
|
1061
1061
|
);
|
|
1062
1062
|
if (!exact) {
|
|
1063
1063
|
result.lines.push(
|
|
@@ -1072,7 +1072,7 @@ export async function absorbOlderLayout(
|
|
|
1072
1072
|
);
|
|
1073
1073
|
if (absorbed.keptMore) {
|
|
1074
1074
|
result.lines.push(
|
|
1075
|
-
|
|
1075
|
+
"profiles.json has entries the old profile format lacked; they were kept — a removal made in the old format is not applied (the old format is backed up)",
|
|
1076
1076
|
);
|
|
1077
1077
|
}
|
|
1078
1078
|
return result;
|
|
@@ -1114,7 +1114,7 @@ async function olderRenderIds(
|
|
|
1114
1114
|
|
|
1115
1115
|
/** One line for a value the older layout's file and profiles.json disagree on. */
|
|
1116
1116
|
export function describeLayoutConflict(at: string, profile: string): string {
|
|
1117
|
-
return `Profile "${profile}": ${at} differs between the
|
|
1117
|
+
return `Profile "${profile}": ${at} differs between the old profile format and profiles.json — kept profiles.json's value (the old one is backed up)`;
|
|
1118
1118
|
}
|
|
1119
1119
|
|
|
1120
1120
|
/**
|
|
@@ -1525,7 +1525,10 @@ export async function reconcileActiveProfile(
|
|
|
1525
1525
|
installSkill: opts.installSkill,
|
|
1526
1526
|
},
|
|
1527
1527
|
);
|
|
1528
|
-
|
|
1528
|
+
// A re-render in the background never reports a tracked file: the
|
|
1529
|
+
// command that ran it reports that once, itself, and repeating it here
|
|
1530
|
+
// printed the same warning three times in one `magus update`.
|
|
1531
|
+
notes.push(...activationNotes(activation, { tracked: false }));
|
|
1529
1532
|
} catch (error) {
|
|
1530
1533
|
result.problems.push(
|
|
1531
1534
|
error instanceof Error ? error.message : String(error),
|
|
@@ -76,9 +76,10 @@ import {
|
|
|
76
76
|
} from "./profile-skills.js";
|
|
77
77
|
import {
|
|
78
78
|
GitCheckError,
|
|
79
|
+
type TrackedNotice,
|
|
80
|
+
trackedNotice,
|
|
79
81
|
trackedPaths,
|
|
80
82
|
trackedSkillFolders,
|
|
81
|
-
trackedWarning,
|
|
82
83
|
} from "./tracked-live-files.js";
|
|
83
84
|
import type { ApplyReporter } from "./update-engine.js";
|
|
84
85
|
|
|
@@ -127,8 +128,11 @@ export function reportDisplaced(
|
|
|
127
128
|
projectPath: string,
|
|
128
129
|
): (artifact: DisplacedArtifact) => void {
|
|
129
130
|
const rel = (p: string) => path.relative(projectPath, p);
|
|
130
|
-
return ({ link
|
|
131
|
-
|
|
131
|
+
return ({ link }) =>
|
|
132
|
+
// The user's file, never where the backup went: that is magus's own
|
|
133
|
+
// storage, and the owner wants only what the user uses on screen.
|
|
134
|
+
// `savedTo` stays in the result for anything that needs to find it.
|
|
135
|
+
console.log(` Backed up your previous ${rel(link)}`);
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
/**
|
|
@@ -157,20 +161,31 @@ export async function trackedGeneratedFiles(
|
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
/**
|
|
160
|
-
* {@link trackedGeneratedFiles} as the
|
|
161
|
-
*
|
|
164
|
+
* {@link trackedGeneratedFiles} as the notices the CLI prints: one per tracked
|
|
165
|
+
* path, or one saying git could not be asked.
|
|
162
166
|
*/
|
|
163
|
-
export async function
|
|
167
|
+
export async function trackedGeneratedNotices(
|
|
164
168
|
projectPath: string,
|
|
165
|
-
): Promise<
|
|
169
|
+
): Promise<TrackedNotice[]> {
|
|
166
170
|
try {
|
|
167
|
-
return (await trackedGeneratedFiles(projectPath)).map(
|
|
171
|
+
return (await trackedGeneratedFiles(projectPath)).map(trackedNotice);
|
|
168
172
|
} catch (error) {
|
|
169
|
-
if (error instanceof GitCheckError)
|
|
173
|
+
if (error instanceof GitCheckError) {
|
|
174
|
+
return [{ problem: error.message, fix: [] }];
|
|
175
|
+
}
|
|
170
176
|
throw error;
|
|
171
177
|
}
|
|
172
178
|
}
|
|
173
179
|
|
|
180
|
+
/** The tracked-path notices an activation result carries. */
|
|
181
|
+
export function activationTrackedNotices(
|
|
182
|
+
result: ActivationResult,
|
|
183
|
+
): TrackedNotice[] {
|
|
184
|
+
return typeof result.tracked === "string"
|
|
185
|
+
? [{ problem: result.tracked, fix: [] }]
|
|
186
|
+
: result.tracked.map(trackedNotice);
|
|
187
|
+
}
|
|
188
|
+
|
|
174
189
|
/**
|
|
175
190
|
* The folders of `skills` the team committed under `.claude/skills/`. Such a
|
|
176
191
|
* folder is the team's copy of the skill: magus neither installs over it nor
|
|
@@ -195,16 +210,21 @@ export function releasedCommittedSkillNote(folder: string): string {
|
|
|
195
210
|
/**
|
|
196
211
|
* Lines describing what activation did beyond the switch itself, phrased for
|
|
197
212
|
* both the terminal and the TUI's outcome modal.
|
|
213
|
+
*
|
|
214
|
+
* `tracked: false` leaves the tracked-path notices out, for the CLI, which
|
|
215
|
+
* prints them itself as a warning with the fix beneath it
|
|
216
|
+
* ({@link activationTrackedNotices}).
|
|
198
217
|
*/
|
|
199
|
-
export function activationNotes(
|
|
218
|
+
export function activationNotes(
|
|
219
|
+
result: ActivationResult,
|
|
220
|
+
opts: { tracked?: boolean } = {},
|
|
221
|
+
): string[] {
|
|
200
222
|
const lines: string[] = [
|
|
201
223
|
...(result.absorbed ?? []),
|
|
202
224
|
...describeDependencies(result.dependencies, result.profileId),
|
|
203
225
|
];
|
|
204
226
|
for (const folder of result.conversion.restored) {
|
|
205
|
-
lines.push(
|
|
206
|
-
`Restored custom skill .claude/skills/${folder} (the older profile layout had moved it)`,
|
|
207
|
-
);
|
|
227
|
+
lines.push(`Restored your custom skill "${folder}"`);
|
|
208
228
|
}
|
|
209
229
|
for (const c of result.conversion.conflicts) {
|
|
210
230
|
lines.push(
|
|
@@ -222,12 +242,13 @@ export function activationNotes(result: ActivationResult): string[] {
|
|
|
222
242
|
`Skill .claude/skills/${folder} is committed to git — the team's copy is used; magus does not install or manage it`,
|
|
223
243
|
);
|
|
224
244
|
}
|
|
225
|
-
if (
|
|
226
|
-
|
|
245
|
+
if (opts.tracked !== false) {
|
|
246
|
+
for (const n of activationTrackedNotices(result)) {
|
|
247
|
+
lines.push(n.problem, ...n.fix);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
227
250
|
if (result.keptAt) {
|
|
228
|
-
lines.push(
|
|
229
|
-
`The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
|
|
230
|
-
);
|
|
251
|
+
lines.push("A backup of the old profile format was kept");
|
|
231
252
|
}
|
|
232
253
|
return lines;
|
|
233
254
|
}
|
|
@@ -495,5 +516,6 @@ export async function recordRefetchedSkills(
|
|
|
495
516
|
|
|
496
517
|
/** The two ways a project can be put onto a profile. */
|
|
497
518
|
export type ProfileChoice =
|
|
498
|
-
|
|
519
|
+
/** `name` is what the user typed; without it the adopted default name is used. */
|
|
520
|
+
| { kind: "adopt"; id: string; name?: string }
|
|
499
521
|
| { kind: "activate"; id: string };
|
|
@@ -36,12 +36,19 @@ export const ADOPTED_PROFILE_ID = "default";
|
|
|
36
36
|
* Lives here rather than at a call site because both surfaces adopt now — the
|
|
37
37
|
* CLI prompt and the TUI's startup offer — and two copies of "which id is
|
|
38
38
|
* free?" is two chances to overwrite somebody's profile.
|
|
39
|
+
*
|
|
40
|
+
* Own keys, the same test `profileIdForName` applies to the name the user
|
|
41
|
+
* submits. A truthiness test called a hand-written `"default": null` free, so
|
|
42
|
+
* the suggestion offered in the name field was the one name it then refused,
|
|
43
|
+
* and Enter looped on it.
|
|
39
44
|
*/
|
|
40
45
|
export function freeProfileId(manifest: ProfileManifest): string {
|
|
41
|
-
if (!manifest.profiles
|
|
46
|
+
if (!Object.hasOwn(manifest.profiles, ADOPTED_PROFILE_ID)) {
|
|
47
|
+
return ADOPTED_PROFILE_ID;
|
|
48
|
+
}
|
|
42
49
|
for (let n = 2; ; n++) {
|
|
43
50
|
const id = `${ADOPTED_PROFILE_ID}-${n}`;
|
|
44
|
-
if (!manifest.profiles
|
|
51
|
+
if (!Object.hasOwn(manifest.profiles, id)) return id;
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
*
|
|
27
27
|
* ok a profile is active; the manifest is in effect; ask nothing
|
|
28
28
|
* unmigrated no manifest — the project is not using profiles at all
|
|
29
|
+
* old-format the project is on an older magus profile format; asked about
|
|
30
|
+
* before it is updated, in the CLI and the TUI alike
|
|
31
|
+
* nested no manifest here, but this folder is inside a project that
|
|
32
|
+
* has one — Claude Code started here would read this folder's
|
|
33
|
+
* settings, not that project's, so nothing is offered: the way
|
|
34
|
+
* on is running magus in that project, or `magus profile init`
|
|
29
35
|
* inactive a manifest with profiles, none of them activated
|
|
30
36
|
* unknown the active-profile marker names a profile the manifest does
|
|
31
37
|
* not define; the only way on is activating one that exists
|
|
@@ -50,18 +56,25 @@
|
|
|
50
56
|
* detects.
|
|
51
57
|
*/
|
|
52
58
|
|
|
59
|
+
import path from "node:path";
|
|
53
60
|
import type { ProfileManifest } from "../types/index.js";
|
|
54
61
|
// Type-only, so it is erased: this module stays free of the UI and of React,
|
|
55
62
|
// which is what lets `magus hook` and the CLI import it. The shape of a
|
|
56
63
|
// selectable option is presentation-neutral data, and both surfaces render the
|
|
57
64
|
// same list from these builders rather than each composing its own.
|
|
58
65
|
import type { SelectOption } from "../ui/state/types.js";
|
|
59
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
MANIFEST_FILE,
|
|
68
|
+
emptyManifest,
|
|
69
|
+
findEnclosingManifest,
|
|
70
|
+
readManifest,
|
|
71
|
+
} from "./manifest.js";
|
|
60
72
|
import type { ProfileChoice } from "./profile-activate.js";
|
|
61
73
|
import { freeProfileId } from "./profile-adopt.js";
|
|
62
74
|
import {
|
|
63
75
|
activeProfile,
|
|
64
76
|
displaceableArtifacts,
|
|
77
|
+
hasOlderLayout,
|
|
65
78
|
namedActiveProfile,
|
|
66
79
|
} from "./profile-live.js";
|
|
67
80
|
import { prepareProfileState } from "./profile-state.js";
|
|
@@ -71,6 +84,8 @@ import { SettingsParseError } from "./settings-file.js";
|
|
|
71
84
|
export type ProfileStartupState =
|
|
72
85
|
| "ok"
|
|
73
86
|
| "unmigrated"
|
|
87
|
+
| "old-format"
|
|
88
|
+
| "nested"
|
|
74
89
|
| "inactive"
|
|
75
90
|
| "unknown"
|
|
76
91
|
| "unparseable";
|
|
@@ -102,6 +117,11 @@ export interface ProfileStartupNeed {
|
|
|
102
117
|
activate: ActivatableProfile[];
|
|
103
118
|
/** For `unknown`: the profile the marker names. */
|
|
104
119
|
unknownProfile?: string;
|
|
120
|
+
/**
|
|
121
|
+
* For `nested`: the enclosing project — its path relative to this folder
|
|
122
|
+
* and its folder name, which is how the user knows it.
|
|
123
|
+
*/
|
|
124
|
+
enclosing?: { rel: string; name: string };
|
|
105
125
|
/**
|
|
106
126
|
* The profile this project declares it wants, if the manifest names one that
|
|
107
127
|
* exists. Used to preselect the row so Enter does the intended thing.
|
|
@@ -167,6 +187,24 @@ export async function detectProfileStartupNeed(
|
|
|
167
187
|
const ids = Object.keys(manifest.profiles);
|
|
168
188
|
|
|
169
189
|
if (ids.length === 0) {
|
|
190
|
+
// One decision for the CLI and the TUI alike. It lived in the CLI alone,
|
|
191
|
+
// so `magus update` in a subfolder explained and stopped while the TUI in
|
|
192
|
+
// the same folder offered to save a second, separate profile.
|
|
193
|
+
const enclosing = await findEnclosingManifest(projectPath);
|
|
194
|
+
if (enclosing !== null) {
|
|
195
|
+
return {
|
|
196
|
+
state: "nested",
|
|
197
|
+
adopt: null,
|
|
198
|
+
activate: [],
|
|
199
|
+
declaredDefault: null,
|
|
200
|
+
autoActivate: false,
|
|
201
|
+
manifest,
|
|
202
|
+
enclosing: {
|
|
203
|
+
rel: path.relative(projectPath, enclosing),
|
|
204
|
+
name: path.basename(enclosing),
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
170
208
|
return {
|
|
171
209
|
state: "unmigrated",
|
|
172
210
|
adopt: freeProfileId(manifest),
|
|
@@ -242,11 +280,45 @@ export async function detectProfileStartupNeed(
|
|
|
242
280
|
};
|
|
243
281
|
}
|
|
244
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The `old-format` question, or null when the project is not on an older
|
|
285
|
+
* magus profile format. One source for the CLI's gate and the TUI's startup,
|
|
286
|
+
* so both ask the same thing — the TUI used to convert without asking while
|
|
287
|
+
* the CLI asked.
|
|
288
|
+
*/
|
|
289
|
+
export async function oldFormatNeed(
|
|
290
|
+
projectPath: string,
|
|
291
|
+
): Promise<ProfileStartupNeed | null> {
|
|
292
|
+
if (!(await hasOlderLayout(projectPath))) return null;
|
|
293
|
+
let manifest: ProfileManifest;
|
|
294
|
+
try {
|
|
295
|
+
manifest = await readManifest(projectPath);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
298
|
+
return unparseableNeed(error);
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
state: "old-format",
|
|
302
|
+
adopt: null,
|
|
303
|
+
activate: [],
|
|
304
|
+
declaredDefault: null,
|
|
305
|
+
autoActivate: false,
|
|
306
|
+
manifest,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
245
310
|
/** What the TUI shows at startup, beyond the question (if any). */
|
|
246
311
|
export interface ProfileStartup {
|
|
247
312
|
need: ProfileStartupNeed;
|
|
248
313
|
/** One line per thing the gate did: absorbed edits, regeneration, repairs. */
|
|
249
314
|
notices: string[];
|
|
315
|
+
/**
|
|
316
|
+
* One line per thing the gate REFUSED — the lines the CLI stops on. Kept
|
|
317
|
+
* apart from `notices` because a refusal can leave detection reading `ok`:
|
|
318
|
+
* an old layout whose settings file does not parse is not converted, yet
|
|
319
|
+
* still names its profile, so only this says the update did not happen.
|
|
320
|
+
*/
|
|
321
|
+
problems: string[];
|
|
250
322
|
}
|
|
251
323
|
|
|
252
324
|
/**
|
|
@@ -257,17 +329,66 @@ export interface ProfileStartup {
|
|
|
257
329
|
*/
|
|
258
330
|
export async function prepareProfileStartup(
|
|
259
331
|
projectPath: string,
|
|
332
|
+
/**
|
|
333
|
+
* Update an older profile format. Without it, a project on one is answered
|
|
334
|
+
* with the `old-format` question and NOTHING is converted — the TUI asks
|
|
335
|
+
* first, as the CLI does; the modal's "Update it now" calls back with this.
|
|
336
|
+
*/
|
|
337
|
+
opts: { convert?: boolean } = {},
|
|
260
338
|
): Promise<ProfileStartup> {
|
|
339
|
+
if (!opts.convert) {
|
|
340
|
+
const format = await oldFormatNeed(projectPath);
|
|
341
|
+
if (format !== null) return { need: format, notices: [], problems: [] };
|
|
342
|
+
}
|
|
261
343
|
const notices: string[] = [];
|
|
344
|
+
const problems: string[] = [];
|
|
262
345
|
try {
|
|
263
346
|
const prep = await prepareProfileState(projectPath);
|
|
264
|
-
notices.push(...prep.lines
|
|
347
|
+
notices.push(...prep.lines);
|
|
348
|
+
problems.push(...prep.problems);
|
|
265
349
|
} catch (error) {
|
|
266
350
|
if (!(error instanceof SettingsParseError)) {
|
|
267
|
-
|
|
351
|
+
problems.push(error instanceof Error ? error.message : String(error));
|
|
268
352
|
}
|
|
269
353
|
}
|
|
270
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
need: await detectProfileStartupNeed(projectPath),
|
|
356
|
+
notices,
|
|
357
|
+
problems,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** What the TUI shows once "Update it now" has run. */
|
|
362
|
+
export type FormatUpdateOutcome =
|
|
363
|
+
| { kind: "ask"; need: ProfileStartupNeed }
|
|
364
|
+
| { kind: "updated"; title: string; message: string }
|
|
365
|
+
| { kind: "failed"; title: string; message: string };
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The verdict on an old-format update, from {@link prepareProfileStartup}'s
|
|
369
|
+
* result. A refusal is a failure even when detection reads `ok` — the CLI
|
|
370
|
+
* stops on the same lines, and the TUI used to show "Profile format updated"
|
|
371
|
+
* over a message saying magus had not updated it.
|
|
372
|
+
*/
|
|
373
|
+
export function formatUpdateOutcome(
|
|
374
|
+
result: ProfileStartup,
|
|
375
|
+
): FormatUpdateOutcome {
|
|
376
|
+
if (result.problems.length > 0) {
|
|
377
|
+
return {
|
|
378
|
+
kind: "failed",
|
|
379
|
+
title: "Could not update the profile format",
|
|
380
|
+
message: [...result.problems, "", "Your profiles are kept."].join("\n"),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
|
|
384
|
+
return {
|
|
385
|
+
kind: "updated",
|
|
386
|
+
title: "Profile format updated",
|
|
387
|
+
message:
|
|
388
|
+
result.notices.length > 0
|
|
389
|
+
? result.notices.join("\n")
|
|
390
|
+
: "This project now uses the current profile format.",
|
|
391
|
+
};
|
|
271
392
|
}
|
|
272
393
|
|
|
273
394
|
// ─── What to offer, as data both surfaces render ─────────────────────────────
|
|
@@ -276,6 +397,8 @@ export async function prepareProfileStartup(
|
|
|
276
397
|
export const ADOPT_VALUE = "adopt";
|
|
277
398
|
export const QUIT_VALUE = "quit";
|
|
278
399
|
export const ACTIVATE_PREFIX = "activate:";
|
|
400
|
+
/** The `old-format` question's "Update it now" row. Not a profile choice. */
|
|
401
|
+
export const UPDATE_FORMAT_VALUE = "update-format";
|
|
279
402
|
|
|
280
403
|
/**
|
|
281
404
|
* Title and message for the state we are in.
|
|
@@ -349,6 +472,35 @@ export function describeNeed(need: ProfileStartupNeed): {
|
|
|
349
472
|
};
|
|
350
473
|
}
|
|
351
474
|
|
|
475
|
+
if (need.state === "old-format") {
|
|
476
|
+
return {
|
|
477
|
+
title: "This project uses an old magus profile format",
|
|
478
|
+
message:
|
|
479
|
+
"magus has to update it before it can manage this project.\n\nYour profiles, plugins and custom skills are kept.",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (need.state === "nested") {
|
|
484
|
+
// The one other place a path appears, and for the same reason as
|
|
485
|
+
// `unparseable`: the folder IS the message. It sits on a line of its own,
|
|
486
|
+
// short, so the modal never wraps it at a `.`. The reason is stated as
|
|
487
|
+
// Claude Code's behaviour, which it is — magus follows it.
|
|
488
|
+
const at = need.enclosing;
|
|
489
|
+
return {
|
|
490
|
+
title: "Inside another Claude Code project",
|
|
491
|
+
message: [
|
|
492
|
+
"This folder is inside a Claude Code project that already has profiles:",
|
|
493
|
+
"",
|
|
494
|
+
` ${at?.rel ?? ".."} (${at?.name ?? "?"})`,
|
|
495
|
+
"",
|
|
496
|
+
"Claude Code treats each folder you start it in as a separate project, with its own plugins and settings, and magus follows it.",
|
|
497
|
+
"",
|
|
498
|
+
"To manage plugins for the whole project, run magus there.",
|
|
499
|
+
"To give this folder a setup of its own, run magus profile init here.",
|
|
500
|
+
].join("\n"),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
352
504
|
if (need.state === "unknown") {
|
|
353
505
|
return {
|
|
354
506
|
title: "Active profile no longer exists",
|
|
@@ -390,6 +542,10 @@ export function describeNeed(need: ProfileStartupNeed): {
|
|
|
390
542
|
export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
|
|
391
543
|
const options: SelectOption[] = [];
|
|
392
544
|
|
|
545
|
+
if (need.state === "old-format") {
|
|
546
|
+
options.push({ label: "Update it now", value: UPDATE_FORMAT_VALUE });
|
|
547
|
+
}
|
|
548
|
+
|
|
393
549
|
if (need.adopt !== null) {
|
|
394
550
|
options.push({
|
|
395
551
|
// Says what it PRESERVES, not what it creates. "Create <id> from this
|
|
@@ -399,7 +555,11 @@ export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
|
|
|
399
555
|
// Kept short on purpose: this is the longest label in the list, and the
|
|
400
556
|
// meta column is measured against the longest one. "…and use it" made it
|
|
401
557
|
// 41 cells and pushed every plugin count onto a line of its own.
|
|
402
|
-
|
|
558
|
+
//
|
|
559
|
+
// No id in it. The generated one read as a copy of an existing profile
|
|
560
|
+
// ("default-2" as "overwrite Default"); the name is asked for once this
|
|
561
|
+
// row is picked, with that id only as the suggestion.
|
|
562
|
+
label: "Save what you have as a new profile",
|
|
403
563
|
value: ADOPT_VALUE,
|
|
404
564
|
});
|
|
405
565
|
}
|
|
@@ -439,6 +599,47 @@ export function defaultOptionIndex(need: ProfileStartupNeed): number {
|
|
|
439
599
|
return at === -1 ? 0 : at;
|
|
440
600
|
}
|
|
441
601
|
|
|
602
|
+
/** A typed profile name, judged: the id it becomes, or why it cannot. */
|
|
603
|
+
export type ProfileNameCheck =
|
|
604
|
+
| { ok: true; id: string; name: string }
|
|
605
|
+
| { ok: false; reason: string };
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Validate a profile name typed by the user and derive its id. Pure.
|
|
609
|
+
*
|
|
610
|
+
* The one rule both surfaces ask with, so the CLI and the TUI cannot accept
|
|
611
|
+
* different names. A clash is refused, never resolved: quietly saving as
|
|
612
|
+
* `<id>-2` is the very thing that made users read the old generated label as
|
|
613
|
+
* "overwrite Default".
|
|
614
|
+
*/
|
|
615
|
+
export function profileIdForName(
|
|
616
|
+
input: string,
|
|
617
|
+
manifest: ProfileManifest,
|
|
618
|
+
): ProfileNameCheck {
|
|
619
|
+
const name = input.trim();
|
|
620
|
+
if (name === "") {
|
|
621
|
+
return { ok: false, reason: "Enter a name for the profile." };
|
|
622
|
+
}
|
|
623
|
+
const id = name
|
|
624
|
+
.toLowerCase()
|
|
625
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
626
|
+
.replace(/^-+|-+$/g, "");
|
|
627
|
+
if (id === "") {
|
|
628
|
+
return { ok: false, reason: "Use at least one letter or digit." };
|
|
629
|
+
}
|
|
630
|
+
// Own keys only: a name like "constructor" is free, not a clash with the
|
|
631
|
+
// object prototype.
|
|
632
|
+
if (Object.hasOwn(manifest.profiles, id)) {
|
|
633
|
+
// Named as the user knows it; a hand-written entry may carry no name.
|
|
634
|
+
const known = manifest.profiles[id]?.name || id;
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
reason: `A profile called "${known}" already exists. Choose another name.`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return { ok: true, id, name };
|
|
641
|
+
}
|
|
642
|
+
|
|
442
643
|
/**
|
|
443
644
|
* Turn a chosen option value into the action it means.
|
|
444
645
|
*
|
|
@@ -261,7 +261,7 @@ export function describeUnknownProfile(
|
|
|
261
261
|
export function describeUnmarked(
|
|
262
262
|
check: Extract<ActiveProfileCheck, { state: "unmarked" }>,
|
|
263
263
|
): string {
|
|
264
|
-
return `No profile is active in this project, and its
|
|
264
|
+
return `No profile is active in this project, and its plugins match none of the profiles. Run ${switchHint(check.available, check.suggested)}.`;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
/**
|
|
@@ -328,7 +328,7 @@ export async function prepareProfileState(
|
|
|
328
328
|
|
|
329
329
|
if (check.state === "active" && check.restored) {
|
|
330
330
|
out.lines.push(
|
|
331
|
-
`
|
|
331
|
+
`Using profile "${check.profile}" — this project's plugins match it`,
|
|
332
332
|
);
|
|
333
333
|
}
|
|
334
334
|
|
|
@@ -353,10 +353,11 @@ export async function prepareProfileState(
|
|
|
353
353
|
);
|
|
354
354
|
if (check.state === "old-layout") {
|
|
355
355
|
out.lines.push(
|
|
356
|
-
`
|
|
356
|
+
`Updated profile "${profile}" to the current profile format`,
|
|
357
357
|
);
|
|
358
358
|
}
|
|
359
|
-
|
|
359
|
+
// Tracked files are the command's to report, once (see profile-absorb).
|
|
360
|
+
out.lines.push(...activationNotes(result, { tracked: false }));
|
|
360
361
|
}
|
|
361
362
|
out.check = { state: "active", profile, restored: false };
|
|
362
363
|
}
|
|
@@ -386,11 +387,11 @@ export async function inspectProfileState(
|
|
|
386
387
|
|
|
387
388
|
if (check.state === "old-layout") {
|
|
388
389
|
pending.push(
|
|
389
|
-
`Profile "${check.profile}"
|
|
390
|
+
`Profile "${check.profile}" uses an old magus profile format. The next magus command, or \`magus doctor --fix\`, updates it — keeping any changes Claude Code made, and a backup of the old format.`,
|
|
390
391
|
);
|
|
391
392
|
} else if (check.state === "unmarked" && check.match !== null) {
|
|
392
393
|
pending.push(
|
|
393
|
-
`
|
|
394
|
+
`This project's plugins match profile "${check.match}" — the next magus command, or \`magus doctor --fix\`, switches to it.`,
|
|
394
395
|
);
|
|
395
396
|
} else if (
|
|
396
397
|
check.state === "active" &&
|
|
@@ -555,7 +556,7 @@ export async function applyProfileChoice(
|
|
|
555
556
|
}
|
|
556
557
|
const result = await adoptCurrentProject(
|
|
557
558
|
projectPath,
|
|
558
|
-
|
|
559
|
+
choice.name,
|
|
559
560
|
manifest,
|
|
560
561
|
choice.id,
|
|
561
562
|
);
|
|
@@ -46,9 +46,30 @@ export function untrackCommand(rel: string): string {
|
|
|
46
46
|
return `git rm --cached ${rel}`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
export
|
|
51
|
-
|
|
49
|
+
/** A tracked generated path, as reported: the problem, then the fix beneath. */
|
|
50
|
+
export interface TrackedNotice {
|
|
51
|
+
problem: string;
|
|
52
|
+
/** Lines under the problem. Empty when git itself could not be asked. */
|
|
53
|
+
fix: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The notice for one tracked generated path.
|
|
58
|
+
*
|
|
59
|
+
* Short lines, not one long one. The single line this replaced ran the
|
|
60
|
+
* problem, the cause and the command together and wrapped mid-command at 80
|
|
61
|
+
* columns; the owner read it as confusing. Now: what is wrong, what the user
|
|
62
|
+
* will see because of it, and the command alone on its line to be copied —
|
|
63
|
+
* each under 80 columns with the report's indent.
|
|
64
|
+
*/
|
|
65
|
+
export function trackedNotice(rel: string): TrackedNotice {
|
|
66
|
+
return {
|
|
67
|
+
problem: `git tracks ${rel}, but magus rewrites it`,
|
|
68
|
+
fix: [
|
|
69
|
+
"It will keep showing as changed. Untrack it — the file stays on disk:",
|
|
70
|
+
`${untrackCommand(rel)}, then commit`,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
/**
|
|
@@ -387,9 +387,16 @@ function describeLoadForward(outcome: StaleLoadRepairResult): string {
|
|
|
387
387
|
/**
|
|
388
388
|
* Scopes the apply loop would touch for `item`. Empty means it is skipped.
|
|
389
389
|
*
|
|
390
|
-
* A plugin the profile declares but nothing has installed
|
|
391
|
-
* scope, matching `install`: a profile is a property of one
|
|
392
|
-
* plugins must not be enabled machine-wide.
|
|
390
|
+
* A plugin the profile declares but nothing has installed (reason `absent`)
|
|
391
|
+
* goes in at PROJECT scope, matching `install`: a profile is a property of one
|
|
392
|
+
* repo, so its plugins must not be enabled machine-wide.
|
|
393
|
+
*
|
|
394
|
+
* A plugin whose files are missing is reinstalled exactly where it is
|
|
395
|
+
* installed — the plan's `scopes`. Every install used to go to project scope,
|
|
396
|
+
* so a user-scope plugin with its files missing was installed with `--scope
|
|
397
|
+
* project`, which turns it on in the project's settings.json; for a plugin the
|
|
398
|
+
* profile does not declare, the reconcile after the run then absorbed that
|
|
399
|
+
* record into the team's committed profiles.json.
|
|
393
400
|
*
|
|
394
401
|
* Every scope holding an outdated install is returned, not just one. Fixing a
|
|
395
402
|
* single hardcoded scope does not converge: it reports success while the
|
|
@@ -408,7 +415,9 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
|
408
415
|
item.action === "stale-load"
|
|
409
416
|
)
|
|
410
417
|
return [];
|
|
411
|
-
return item.action === "install"
|
|
418
|
+
return item.action === "install" && item.reason === "absent"
|
|
419
|
+
? ["project"]
|
|
420
|
+
: item.scopes;
|
|
412
421
|
}
|
|
413
422
|
|
|
414
423
|
/**
|
|
@@ -451,8 +460,11 @@ export function pluginNeedsWork(item: PluginUpdateItem): boolean {
|
|
|
451
460
|
* report "0.2.4 installed, 0.3.0 available" while owning no row of its own.
|
|
452
461
|
* That is exactly the shape that used to reach `update`.
|
|
453
462
|
*
|
|
454
|
-
* `readInstalled`
|
|
455
|
-
* which is what makes it the
|
|
463
|
+
* `readInstalled` reads this project's own row at `scope` by exact path — no
|
|
464
|
+
* worktree inheritance and no user-row fallback — which is what makes it the
|
|
465
|
+
* right question to ask here. It used to fall back to the user row, so a plugin
|
|
466
|
+
* installed only at user scope and turned on in project settings passed this
|
|
467
|
+
* check and reached `update --scope project` from a folder with no project row.
|
|
456
468
|
*/
|
|
457
469
|
async function advanceInScope(
|
|
458
470
|
deps: PluginApplyDeps,
|
|
@@ -487,9 +499,14 @@ async function advanceInScope(
|
|
|
487
499
|
* No installer output is parsed — "already at the latest version" is
|
|
488
500
|
* locale- and version-fragile. The registry row the apply loop already reads
|
|
489
501
|
* back is compared with the before-version the plan carries:
|
|
490
|
-
* - update:
|
|
502
|
+
* - update: no touched scope recorded a version it did not hold before;
|
|
491
503
|
* - install: no touched scope reads back any version at all;
|
|
492
504
|
* - repair: never — same version is its definition; the files moved.
|
|
505
|
+
*
|
|
506
|
+
* A null read-back is nothing recorded, so it is never a move. It used to
|
|
507
|
+
* count as one — null differs from any before-version — and since the
|
|
508
|
+
* read-back matches this project's own row exactly, an update that recorded
|
|
509
|
+
* nothing reported "project → <target>" on every run while nothing changed.
|
|
493
510
|
*/
|
|
494
511
|
function pluginNoMove(
|
|
495
512
|
item: PluginUpdateItem,
|
|
@@ -497,10 +514,11 @@ function pluginNoMove(
|
|
|
497
514
|
): string | null {
|
|
498
515
|
if (landed.length === 0) return null;
|
|
499
516
|
if (item.action === "update") {
|
|
500
|
-
const
|
|
501
|
-
([scope, actual]) =>
|
|
517
|
+
const moved = landed.some(
|
|
518
|
+
([scope, actual]) =>
|
|
519
|
+
actual !== null && actual !== item.beforeVersions[scope],
|
|
502
520
|
);
|
|
503
|
-
if (
|
|
521
|
+
if (moved) return null;
|
|
504
522
|
const v = landed.find(([, a]) => a)?.[1];
|
|
505
523
|
return v
|
|
506
524
|
? `the installer exited 0 but ${item.pluginId} is still ${v}`
|