magus-cli 7.5.3 → 7.5.4
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__/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-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-state.test.ts +27 -22
- 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 +239 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -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 +39 -18
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -6
- 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 +7 -2
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useProfileStartupModal.ts +87 -7
- 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
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -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" &&
|
|
@@ -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}`
|