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
package/src/cli/bootstrap.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ActivationResult,
|
|
19
19
|
activateProfile,
|
|
20
20
|
activationNotes,
|
|
21
|
+
activationTrackedNotices,
|
|
21
22
|
reportDisplaced,
|
|
22
23
|
} from "../services/profile-activate.js";
|
|
23
24
|
import {
|
|
@@ -25,11 +26,14 @@ import {
|
|
|
25
26
|
adoptCurrentProject,
|
|
26
27
|
} from "../services/profile-adopt.js";
|
|
27
28
|
import {
|
|
29
|
+
type ProfileStartupNeed,
|
|
30
|
+
UPDATE_FORMAT_VALUE,
|
|
28
31
|
buildProfileOptions,
|
|
29
32
|
defaultOptionIndex,
|
|
30
33
|
describeNeed,
|
|
31
34
|
detectProfileStartupNeed,
|
|
32
35
|
needsNothing,
|
|
36
|
+
oldFormatNeed,
|
|
33
37
|
parseProfileChoice,
|
|
34
38
|
} from "../services/profile-startup.js";
|
|
35
39
|
import {
|
|
@@ -41,10 +45,23 @@ import {
|
|
|
41
45
|
} from "../services/profile-state.js";
|
|
42
46
|
import { SettingsParseError } from "../services/settings-file.js";
|
|
43
47
|
import type { ProfileManifest } from "../types/index.js";
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
+
import { brand } from "../ui/theme.js";
|
|
49
|
+
import { badge, bold, dim } from "./ansi.js";
|
|
50
|
+
import { askYesNo } from "./prompt.js";
|
|
51
|
+
import { selectOption } from "./select.js";
|
|
52
|
+
import {
|
|
53
|
+
type ScopeName,
|
|
54
|
+
fail as failLine,
|
|
55
|
+
field,
|
|
56
|
+
info as infoLine,
|
|
57
|
+
listItem,
|
|
58
|
+
noteBlock,
|
|
59
|
+
note as noteLine,
|
|
60
|
+
ok as okLine,
|
|
61
|
+
question,
|
|
62
|
+
scopeBadge,
|
|
63
|
+
warn as warnLine,
|
|
64
|
+
} from "./update-view.js";
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
67
|
* Report a file that exists and cannot be used — it does not parse, or parses
|
|
@@ -76,6 +93,13 @@ export function reportUnparseable(error: SettingsParseError): void {
|
|
|
76
93
|
async function writeAndActivate(
|
|
77
94
|
result: AdoptResult,
|
|
78
95
|
projectPath: string,
|
|
96
|
+
/**
|
|
97
|
+
* Report a generated file git tracks. Only when adopting IS the command
|
|
98
|
+
* (`profile init`): `install` and `update` report it themselves right
|
|
99
|
+
* after — install's switch, update's own check — so adoption inside them
|
|
100
|
+
* said it twice in one run.
|
|
101
|
+
*/
|
|
102
|
+
reportTracked: boolean,
|
|
79
103
|
): Promise<void> {
|
|
80
104
|
// Relative to the project throughout. These are all project paths, and an
|
|
81
105
|
// absolute one wraps over two terminal lines — several wrapped lines in a row
|
|
@@ -84,7 +108,12 @@ async function writeAndActivate(
|
|
|
84
108
|
const rel = (p: string) => path.relative(projectPath, p);
|
|
85
109
|
|
|
86
110
|
await writeManifest(result.manifest, projectPath);
|
|
87
|
-
console.log(
|
|
111
|
+
console.log(okLine(`Saved profile ${bold(result.profileId)}`));
|
|
112
|
+
console.log(
|
|
113
|
+
noteLine(
|
|
114
|
+
`Commit ${rel(getManifestPath(projectPath))} so your team gets the same plugins.`,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
88
117
|
|
|
89
118
|
// Each move is printed as it happens, not from the returned list afterwards.
|
|
90
119
|
// Activation writes three files in sequence, so a throw on the third would
|
|
@@ -96,14 +125,28 @@ async function writeAndActivate(
|
|
|
96
125
|
projectPath,
|
|
97
126
|
{ onDisplace: reportDisplaced(projectPath) },
|
|
98
127
|
);
|
|
99
|
-
console.log(
|
|
100
|
-
printActivationNotes(activation);
|
|
128
|
+
console.log(okLine(`Using profile ${bold(result.profileId)}`));
|
|
129
|
+
printActivationNotes(activation, { tracked: reportTracked });
|
|
101
130
|
console.log();
|
|
102
131
|
}
|
|
103
132
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Print what activation did beyond the switch itself: notes one line each, and
|
|
135
|
+
* — unless `tracked` is false — a tracked generated file as a warning with its
|
|
136
|
+
* fix beneath.
|
|
137
|
+
*/
|
|
138
|
+
export function printActivationNotes(
|
|
139
|
+
result: ActivationResult,
|
|
140
|
+
opts: { tracked?: boolean } = {},
|
|
141
|
+
): void {
|
|
142
|
+
for (const line of activationNotes(result, { tracked: false })) {
|
|
143
|
+
console.log(noteLine(line));
|
|
144
|
+
}
|
|
145
|
+
if (opts.tracked === false) return;
|
|
146
|
+
for (const n of activationTrackedNotices(result)) {
|
|
147
|
+
console.log(warnLine(n.problem));
|
|
148
|
+
for (const line of n.fix) console.log(noteLine(line));
|
|
149
|
+
}
|
|
107
150
|
}
|
|
108
151
|
|
|
109
152
|
export interface ProfileGateOptions {
|
|
@@ -113,6 +156,39 @@ export interface ProfileGateOptions {
|
|
|
113
156
|
* exists — `profile switch`, `install`.
|
|
114
157
|
*/
|
|
115
158
|
allowUnknown?: boolean;
|
|
159
|
+
/** `--yes`: update an old profile format without asking. */
|
|
160
|
+
yes?: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* An old magus profile format: the shared `old-format` state, stated with the
|
|
165
|
+
* TUI's own title and message and — with a person at the terminal and no
|
|
166
|
+
* `--yes` — asked with the TUI's own rows. Returns false when the user
|
|
167
|
+
* declines; the command then stops, since magus cannot manage a project in a
|
|
168
|
+
* format it no longer writes. With nobody to ask it updates, as before: CI and
|
|
169
|
+
* `--yes` must not hang on a question.
|
|
170
|
+
*/
|
|
171
|
+
async function confirmFormatUpdate(
|
|
172
|
+
projectPath: string,
|
|
173
|
+
opts: ProfileGateOptions,
|
|
174
|
+
): Promise<boolean> {
|
|
175
|
+
const need = await oldFormatNeed(projectPath);
|
|
176
|
+
if (need === null || need.state !== "old-format") return true;
|
|
177
|
+
const { title, message } = describeNeed(need);
|
|
178
|
+
console.log("");
|
|
179
|
+
console.log(warnLine(bold(title)));
|
|
180
|
+
for (const line of noteBlock(message, process.stdout.columns)) {
|
|
181
|
+
console.log(line);
|
|
182
|
+
}
|
|
183
|
+
console.log("");
|
|
184
|
+
if (opts.yes || !process.stdin.isTTY || !process.stdout.isTTY) return true;
|
|
185
|
+
const options = buildProfileOptions(need);
|
|
186
|
+
const picked = await selectOption("Update the profile format?", options, 0);
|
|
187
|
+
if (picked !== null && options[picked]?.value === UPDATE_FORMAT_VALUE) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
console.log(infoLine("Nothing changed."));
|
|
191
|
+
return false;
|
|
116
192
|
}
|
|
117
193
|
|
|
118
194
|
/**
|
|
@@ -129,6 +205,7 @@ export async function profileGate(
|
|
|
129
205
|
projectPath: string,
|
|
130
206
|
opts: ProfileGateOptions = {},
|
|
131
207
|
): Promise<boolean> {
|
|
208
|
+
if (!(await confirmFormatUpdate(projectPath, opts))) return false;
|
|
132
209
|
let prep: Awaited<ReturnType<typeof prepareProfileState>>;
|
|
133
210
|
try {
|
|
134
211
|
prep = await prepareProfileState(projectPath);
|
|
@@ -154,64 +231,128 @@ export async function profileGate(
|
|
|
154
231
|
return true;
|
|
155
232
|
}
|
|
156
233
|
|
|
157
|
-
/**
|
|
234
|
+
/** The plugin as a user reads it: its name, and its marketplace only when that is not magus's own. */
|
|
235
|
+
function pluginLabel(id: string): string {
|
|
236
|
+
const at = id.lastIndexOf("@");
|
|
237
|
+
if (at <= 0) return id;
|
|
238
|
+
const market = id.slice(at + 1);
|
|
239
|
+
return market === "magus"
|
|
240
|
+
? id.slice(0, at)
|
|
241
|
+
: `${id.slice(0, at)} ${dim(market)}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Longest list shown before it is cut to "+ N more". */
|
|
245
|
+
const LIST_MAX = 12;
|
|
246
|
+
|
|
247
|
+
/** Print up to LIST_MAX entries under a field, then how many were left out. */
|
|
248
|
+
function printList(entries: string[]): void {
|
|
249
|
+
for (const entry of entries.slice(0, LIST_MAX)) console.log(listItem(entry));
|
|
250
|
+
if (entries.length > LIST_MAX) {
|
|
251
|
+
console.log(listItem(dim(`+ ${entries.length - LIST_MAX} more`)));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Print the state and what adoption would capture, so the user can judge it
|
|
257
|
+
* before the question below.
|
|
258
|
+
*
|
|
259
|
+
* In the user's terms only: the state ("no profiles yet"), the plugins as a
|
|
260
|
+
* list with the SCOPE they come from, and anything that affects the team.
|
|
261
|
+
* Never where magus stores things — the owner, on the version that printed
|
|
262
|
+
* "from this folder's .claude/settings.json" and a relative path: users
|
|
263
|
+
* operate in scopes (user, project, local), not in files, and a mechanism
|
|
264
|
+
* they never chose is noise.
|
|
265
|
+
*/
|
|
158
266
|
function printAdoptionPlan(result: AdoptResult): void {
|
|
267
|
+
console.log("");
|
|
268
|
+
console.log(warnLine(bold("No profiles in this project yet")));
|
|
159
269
|
console.log(
|
|
160
|
-
|
|
270
|
+
noteLine(
|
|
271
|
+
"magus keeps a project's plugins in a profile: a named set your team gets",
|
|
272
|
+
),
|
|
161
273
|
);
|
|
162
|
-
console.log(` profile ${result.profileId} (${result.entry.name})`);
|
|
163
274
|
console.log(
|
|
164
|
-
|
|
275
|
+
noteLine(
|
|
276
|
+
"by committing it. Save what this project uses now as the first one.",
|
|
277
|
+
),
|
|
165
278
|
);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
279
|
+
console.log("");
|
|
280
|
+
|
|
281
|
+
console.log(
|
|
282
|
+
field(
|
|
283
|
+
"profile",
|
|
284
|
+
`${bold(result.profileId)} ${badge("NEW", brand.success)}`,
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
const scope: ScopeName = result.source === "user" ? "user" : "project";
|
|
288
|
+
console.log(
|
|
289
|
+
field(
|
|
290
|
+
"plugins",
|
|
291
|
+
`${bold(String(result.counts.plugins))} ${scopeBadge(scope)}`,
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
printList(Object.keys(result.entry.plugins ?? {}).map(pluginLabel));
|
|
295
|
+
const servers = Object.keys(result.entry.mcpServers ?? {});
|
|
296
|
+
if (servers.length > 0) {
|
|
297
|
+
console.log(field("mcp servers", bold(String(servers.length))));
|
|
298
|
+
printList(servers);
|
|
179
299
|
}
|
|
180
|
-
|
|
300
|
+
console.log("");
|
|
301
|
+
|
|
302
|
+
const warned =
|
|
303
|
+
result.source === "user" ||
|
|
304
|
+
result.skipped.length > 0 ||
|
|
305
|
+
result.machinePaths.length > 0;
|
|
306
|
+
if (result.source === "user") {
|
|
307
|
+
// Never let this be invisible: the profile is meant to be committed, and
|
|
308
|
+
// these are one person's global set, not anything this project chose.
|
|
309
|
+
console.log(warnLine("This project has no plugins of its own"));
|
|
181
310
|
console.log(
|
|
182
|
-
|
|
311
|
+
noteLine(
|
|
312
|
+
"These are your USER plugins, on in every project. Check them before committing.",
|
|
313
|
+
),
|
|
183
314
|
);
|
|
184
315
|
}
|
|
185
316
|
for (const id of result.skipped) {
|
|
186
317
|
console.log(
|
|
187
|
-
|
|
318
|
+
warnLine(`${id} can't be shared — it isn't from a marketplace`),
|
|
188
319
|
);
|
|
189
320
|
}
|
|
190
321
|
for (const hit of result.machinePaths) {
|
|
191
|
-
console.log(` ⚠ machine path ${hit}`);
|
|
192
|
-
}
|
|
193
|
-
if (result.machinePaths.length > 0) {
|
|
194
322
|
console.log(
|
|
195
|
-
|
|
323
|
+
warnLine(`A setting points to a folder on this computer: ${hit}`),
|
|
196
324
|
);
|
|
197
325
|
}
|
|
198
|
-
|
|
199
|
-
"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
326
|
+
if (result.machinePaths.length > 0) {
|
|
327
|
+
console.log(noteLine("Your teammates won't have it."));
|
|
328
|
+
}
|
|
329
|
+
if (warned) console.log("");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A state that stops the command, stated the way the TUI states it: the same
|
|
334
|
+
* title and message from `describeNeed`, dim — nothing is wrong, and there is
|
|
335
|
+
* nothing to choose from the CLI.
|
|
336
|
+
*/
|
|
337
|
+
function printNeedNote(need: ProfileStartupNeed): void {
|
|
338
|
+
const { title, message } = describeNeed(need);
|
|
339
|
+
console.log("");
|
|
340
|
+
console.log(noteLine(bold(title)));
|
|
341
|
+
for (const line of noteBlock(message, process.stdout.columns)) {
|
|
342
|
+
console.log(line);
|
|
343
|
+
}
|
|
344
|
+
console.log("");
|
|
210
345
|
}
|
|
211
346
|
|
|
212
347
|
export interface EnsureManifestOptions {
|
|
213
348
|
/** Read-only mode: never write, just report that adoption is needed. */
|
|
214
349
|
check?: boolean;
|
|
350
|
+
/**
|
|
351
|
+
* Report a tracked generated file after activating. False for a caller that
|
|
352
|
+
* already reported it this run (`update` checks before it asks), so it is
|
|
353
|
+
* said once.
|
|
354
|
+
*/
|
|
355
|
+
reportTracked?: boolean;
|
|
215
356
|
/**
|
|
216
357
|
* Adopt without prompting. Set only by `magus profile init`, where
|
|
217
358
|
* creating the manifest IS the command the user asked for.
|
|
@@ -241,14 +382,30 @@ export async function ensureManifest(
|
|
|
241
382
|
}
|
|
242
383
|
if (Object.keys(manifest.profiles).length > 0) return manifest;
|
|
243
384
|
|
|
385
|
+
// Inside a project that has profiles: the shared startup state says so, and
|
|
386
|
+
// the command stops — the TUI shows the same state as its startup question.
|
|
387
|
+
// Only `profile init` (`allowAdopt`), run on purpose, gives a subfolder a
|
|
388
|
+
// setup of its own.
|
|
389
|
+
if (!opts.allowAdopt) {
|
|
390
|
+
const need = await detectProfileStartupNeed(projectPath);
|
|
391
|
+
if (need.state === "nested") {
|
|
392
|
+
printNeedNote(need);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
244
397
|
const result = await adoptCurrentProject(projectPath);
|
|
245
398
|
|
|
246
399
|
if (result.counts.plugins === 0) {
|
|
247
400
|
console.error(
|
|
248
|
-
|
|
401
|
+
failLine(
|
|
402
|
+
`No profile in ${path.basename(projectPath)}, and no plugins to save as one`,
|
|
403
|
+
),
|
|
249
404
|
);
|
|
250
405
|
console.error(
|
|
251
|
-
|
|
406
|
+
noteLine(
|
|
407
|
+
"Open magus, pick the plugins this folder needs, then run this again.",
|
|
408
|
+
),
|
|
252
409
|
);
|
|
253
410
|
return null;
|
|
254
411
|
}
|
|
@@ -257,8 +414,9 @@ export async function ensureManifest(
|
|
|
257
414
|
|
|
258
415
|
if (opts.check) {
|
|
259
416
|
console.error(
|
|
260
|
-
"
|
|
417
|
+
failLine("--check is read-only, so it does not create the profile"),
|
|
261
418
|
);
|
|
419
|
+
console.error(noteLine("Run `magus install` once to create it."));
|
|
262
420
|
return null;
|
|
263
421
|
}
|
|
264
422
|
|
|
@@ -272,15 +430,20 @@ export async function ensureManifest(
|
|
|
272
430
|
// fall through to the write
|
|
273
431
|
} else if (!process.stdin.isTTY) {
|
|
274
432
|
console.error(
|
|
275
|
-
"
|
|
433
|
+
failLine("No profile in this folder, and nobody here to ask"),
|
|
434
|
+
);
|
|
435
|
+
console.error(
|
|
436
|
+
noteLine("Run `magus profile init` to create one deliberately."),
|
|
276
437
|
);
|
|
277
438
|
return null;
|
|
278
|
-
} else if (
|
|
279
|
-
|
|
439
|
+
} else if (
|
|
440
|
+
!(await askYesNo(question(`Save as profile "${result.profileId}"?`)))
|
|
441
|
+
) {
|
|
442
|
+
console.log(infoLine("Nothing written."));
|
|
280
443
|
return null;
|
|
281
444
|
}
|
|
282
445
|
|
|
283
|
-
await writeAndActivate(result, projectPath);
|
|
446
|
+
await writeAndActivate(result, projectPath, opts.allowAdopt === true);
|
|
284
447
|
return result.manifest;
|
|
285
448
|
}
|
|
286
449
|
|
|
@@ -331,7 +494,7 @@ export async function requireActiveProfile(
|
|
|
331
494
|
console.log(
|
|
332
495
|
`✓ Active profile: ${result.profileId} ${dim("(declared by this project)")}`,
|
|
333
496
|
);
|
|
334
|
-
printActivationNotes(result);
|
|
497
|
+
printActivationNotes(result, { tracked: opts.reportTracked !== false });
|
|
335
498
|
console.log("");
|
|
336
499
|
return readManifest(projectPath);
|
|
337
500
|
}
|
|
@@ -347,7 +510,9 @@ export async function requireActiveProfile(
|
|
|
347
510
|
}
|
|
348
511
|
|
|
349
512
|
// CI has nobody to ask. Exiting beats hanging on an answer that cannot come.
|
|
350
|
-
|
|
513
|
+
// Both ends: with stdout piped the list cannot draw, and "No profile chosen"
|
|
514
|
+
// would claim an answer nobody was asked for.
|
|
515
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
351
516
|
console.error(failLine(title));
|
|
352
517
|
console.error(
|
|
353
518
|
noteLine(
|
|
@@ -362,27 +527,35 @@ export async function requireActiveProfile(
|
|
|
362
527
|
return null;
|
|
363
528
|
}
|
|
364
529
|
|
|
530
|
+
// The choices the TUI's startup modal offers, built by the same functions,
|
|
531
|
+
// as an arrow-key list in the report style — the CLI mounts no TUI.
|
|
532
|
+
const options = buildProfileOptions(need);
|
|
533
|
+
const def = defaultOptionIndex(need);
|
|
365
534
|
console.log("");
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
535
|
+
console.log(warnLine(bold(title)));
|
|
536
|
+
for (const line of noteBlock(
|
|
537
|
+
describeNeed(need).message,
|
|
538
|
+
process.stdout.columns,
|
|
539
|
+
)) {
|
|
540
|
+
console.log(line);
|
|
541
|
+
}
|
|
542
|
+
console.log("");
|
|
543
|
+
const picked = await selectOption("Choose a profile", options, def);
|
|
372
544
|
|
|
373
|
-
//
|
|
545
|
+
// "Quit magus", Esc and Ctrl+C all land here, and there is no third
|
|
374
546
|
// outcome: the command stops rather than proceed without a profile.
|
|
375
|
-
const
|
|
547
|
+
const value = picked === null ? undefined : options[picked]?.value;
|
|
548
|
+
const choice = value === undefined ? null : parseProfileChoice(value, need);
|
|
376
549
|
if (!choice) {
|
|
377
|
-
console.error(failLine("No
|
|
550
|
+
console.error(failLine("No profile chosen — stopping."));
|
|
378
551
|
return null;
|
|
379
552
|
}
|
|
380
553
|
|
|
381
554
|
const result = await applyProfileChoice(choice, need.manifest, projectPath, {
|
|
382
555
|
onDisplace: reportDisplaced(projectPath),
|
|
383
556
|
});
|
|
384
|
-
console.log(
|
|
385
|
-
printActivationNotes(result);
|
|
557
|
+
console.log(okLine(`Using profile ${bold(result.profileId)}`));
|
|
558
|
+
printActivationNotes(result, { tracked: opts.reportTracked !== false });
|
|
386
559
|
console.log("");
|
|
387
560
|
|
|
388
561
|
// Re-read rather than return `need.manifest`: adopting wrote a new profile
|
package/src/cli/doctor.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
type RepoRootCache,
|
|
47
47
|
pickLoadedEntry,
|
|
48
48
|
} from "../services/plugin-resolution.js";
|
|
49
|
-
import {
|
|
49
|
+
import { trackedGeneratedNotices } from "../services/profile-activate.js";
|
|
50
50
|
import {
|
|
51
51
|
LIVE_FILE_KEYS,
|
|
52
52
|
liveKind,
|
|
@@ -555,8 +555,9 @@ export async function reportProfileState(
|
|
|
555
555
|
// A .gitignore entry does not untrack a file committed before it. A note
|
|
556
556
|
// with the fix, not a failure: magus writes the file all the same, and
|
|
557
557
|
// never runs git rm itself.
|
|
558
|
-
for (const
|
|
559
|
-
console.log(`• ${
|
|
558
|
+
for (const n of await trackedGeneratedNotices(projectPath)) {
|
|
559
|
+
console.log(`• ${n.problem}`);
|
|
560
|
+
for (const line of n.fix) console.log(` ${line}`);
|
|
560
561
|
}
|
|
561
562
|
return { fixable, unfixable };
|
|
562
563
|
}
|
package/src/cli/install.ts
CHANGED
|
@@ -48,9 +48,20 @@ import { bringLoadForward } from "../services/update-engine.js";
|
|
|
48
48
|
import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
|
|
49
49
|
import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
|
|
50
50
|
import { runCommand, runShellScript } from "../utils/run.js";
|
|
51
|
+
import { bold } from "./ansi.js";
|
|
51
52
|
import { ensureManifest, printActivationNotes } from "./bootstrap.js";
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
53
|
+
import { askYesNo, promptValue } from "./prompt.js";
|
|
54
|
+
import { selectOption } from "./select.js";
|
|
55
|
+
import {
|
|
56
|
+
commandHeader,
|
|
57
|
+
fail as failLine,
|
|
58
|
+
field,
|
|
59
|
+
info as infoLine,
|
|
60
|
+
note as noteLine,
|
|
61
|
+
ok as okLine,
|
|
62
|
+
question,
|
|
63
|
+
warn as warnLine,
|
|
64
|
+
} from "./update-view.js";
|
|
54
65
|
|
|
55
66
|
interface InstallFlags {
|
|
56
67
|
check: boolean;
|
|
@@ -119,8 +130,10 @@ async function ensureToolchains(
|
|
|
119
130
|
}
|
|
120
131
|
const ok =
|
|
121
132
|
yes ||
|
|
122
|
-
(await
|
|
123
|
-
|
|
133
|
+
(await askYesNo(
|
|
134
|
+
question(
|
|
135
|
+
`${tc.name} is missing — ${tc.requiredBy.join(", ")} need${tc.requiredBy.length === 1 ? "s" : ""} it. Install it?`,
|
|
136
|
+
),
|
|
124
137
|
));
|
|
125
138
|
if (ok) {
|
|
126
139
|
console.log(`Installing ${tc.name}…`);
|
|
@@ -149,15 +162,18 @@ async function ensureToolchains(
|
|
|
149
162
|
* EVERY profile's plugins — so the caller runs this inside
|
|
150
163
|
* `withCliBookkeeping`, which renders the live files over it afterwards.
|
|
151
164
|
*/
|
|
152
|
-
async function installPlugins(closure: ResolvedClosure): Promise<
|
|
165
|
+
async function installPlugins(closure: ResolvedClosure): Promise<string[]> {
|
|
166
|
+
const installed: string[] = [];
|
|
153
167
|
for (const pluginId of Object.keys(closure.plugins)) {
|
|
154
168
|
try {
|
|
155
169
|
console.log(`+ plugin ${pluginId}`);
|
|
156
170
|
await installPlugin(pluginId, "project");
|
|
171
|
+
installed.push(pluginId);
|
|
157
172
|
} catch (e) {
|
|
158
173
|
console.warn(`⚠ plugin ${pluginId}: ${(e as Error).message}`);
|
|
159
174
|
}
|
|
160
175
|
}
|
|
176
|
+
return installed;
|
|
161
177
|
}
|
|
162
178
|
|
|
163
179
|
/**
|
|
@@ -176,7 +192,15 @@ async function bringLoadsForward(
|
|
|
176
192
|
"project",
|
|
177
193
|
projectPath,
|
|
178
194
|
);
|
|
179
|
-
if (!here)
|
|
195
|
+
if (!here) {
|
|
196
|
+
// Skipped rather than checked against another scope's record: that
|
|
197
|
+
// would repair toward a version this project does not have. Said, so a
|
|
198
|
+
// plugin that installed but left nothing to check is not silent.
|
|
199
|
+
console.warn(
|
|
200
|
+
`⚠ ${pluginId}: installed, but Claude Code recorded no version for this project, so magus cannot check which version loads here`,
|
|
201
|
+
);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
180
204
|
const forward = await bringLoadForward(pluginId, here, projectPath);
|
|
181
205
|
if (!forward) continue;
|
|
182
206
|
if (forward.ok) {
|
|
@@ -314,18 +338,32 @@ export async function runInstallCommand(
|
|
|
314
338
|
}
|
|
315
339
|
|
|
316
340
|
// Plan
|
|
317
|
-
|
|
318
|
-
|
|
341
|
+
// In the report style `update` uses: a header, then the plan as aligned
|
|
342
|
+
// rows. `summarizeClosure` pads each line as "label values"; split at the
|
|
343
|
+
// padding so the label takes the dim label column.
|
|
344
|
+
for (const line of commandHeader(
|
|
345
|
+
"install",
|
|
346
|
+
flags.profile
|
|
347
|
+
? { label: "profile", value: flags.profile }
|
|
348
|
+
: { label: "profiles", value: "all" },
|
|
349
|
+
)) {
|
|
350
|
+
console.log(line);
|
|
351
|
+
}
|
|
352
|
+
for (const line of summarizeClosure(closure)) {
|
|
353
|
+
const [label, ...rest] = line.split(/ {2,}/);
|
|
354
|
+
console.log(field(label ?? "", rest.join(" ")));
|
|
355
|
+
}
|
|
356
|
+
console.log("");
|
|
319
357
|
if (closure.conflicts?.length) {
|
|
320
358
|
console.log(
|
|
321
|
-
"
|
|
359
|
+
warnLine("Profiles pin different versions — the higher pin wins"),
|
|
322
360
|
);
|
|
323
|
-
for (const c of closure.conflicts) console.log(
|
|
361
|
+
for (const c of closure.conflicts) console.log(noteLine(c));
|
|
362
|
+
console.log("");
|
|
324
363
|
}
|
|
325
|
-
console.log();
|
|
326
364
|
|
|
327
|
-
if (!flags.yes && !(await
|
|
328
|
-
console.log("
|
|
365
|
+
if (!flags.yes && !(await askYesNo(question("Install this plan?")))) {
|
|
366
|
+
console.log(infoLine("Nothing installed."));
|
|
329
367
|
return 0;
|
|
330
368
|
}
|
|
331
369
|
|
|
@@ -363,6 +401,10 @@ export async function runInstallCommand(
|
|
|
363
401
|
flags.profile ??
|
|
364
402
|
current ??
|
|
365
403
|
(await pickActiveProfile(profileIds, flags.yes));
|
|
404
|
+
if (activeId === null) {
|
|
405
|
+
console.error(failLine("No profile chosen — stopping."));
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
366
408
|
if (activeId) {
|
|
367
409
|
// The one switch path: what Claude Code wrote into the live config — or
|
|
368
410
|
// through an older layout's links — goes into the profile it was written
|
|
@@ -375,10 +417,10 @@ export async function runInstallCommand(
|
|
|
375
417
|
});
|
|
376
418
|
for (const line of switched.lines) console.log(noteLine(line));
|
|
377
419
|
if (!switched.activation) {
|
|
378
|
-
for (const problem of switched.problems) console.error(problem);
|
|
420
|
+
for (const problem of switched.problems) console.error(failLine(problem));
|
|
379
421
|
return 1;
|
|
380
422
|
}
|
|
381
|
-
console.log(
|
|
423
|
+
console.log(okLine(`Active profile ${bold(activeId)}`));
|
|
382
424
|
printActivationNotes(switched.activation);
|
|
383
425
|
}
|
|
384
426
|
|
|
@@ -387,11 +429,16 @@ export async function runInstallCommand(
|
|
|
387
429
|
// while they run and reconciles in `finally`: what the user changed in the
|
|
388
430
|
// meantime is kept, only the install's own records are discarded, and a
|
|
389
431
|
// Ctrl-C leaves the flag for the next command to do the same.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
432
|
+
const installed = await withCliBookkeeping(
|
|
433
|
+
projectPath,
|
|
434
|
+
() => installPlugins(closure),
|
|
435
|
+
{
|
|
436
|
+
installable: Object.keys(closure.plugins),
|
|
437
|
+
report: (line) => console.log(noteLine(line)),
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
// Only what installed: a failed install already printed its own warning.
|
|
441
|
+
await bringLoadsForward(installed, projectPath);
|
|
395
442
|
await installBins(closure.bins);
|
|
396
443
|
|
|
397
444
|
if (activeId) {
|
|
@@ -406,16 +453,25 @@ export async function runInstallCommand(
|
|
|
406
453
|
return 0;
|
|
407
454
|
}
|
|
408
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Which profile to make active when none is: the only one, the first under
|
|
458
|
+
* `--yes` or with nobody at the terminal, else the user's pick from an
|
|
459
|
+
* arrow-key list. Null when the user cancels the list — the install stops then,
|
|
460
|
+
* since magus does not run a project without a profile.
|
|
461
|
+
*/
|
|
409
462
|
async function pickActiveProfile(
|
|
410
463
|
profileIds: string[],
|
|
411
464
|
yes: boolean,
|
|
412
|
-
): Promise<string | undefined> {
|
|
465
|
+
): Promise<string | undefined | null> {
|
|
413
466
|
if (profileIds.length === 0) return undefined;
|
|
414
467
|
if (profileIds.length === 1) return profileIds[0];
|
|
415
|
-
if (yes)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
468
|
+
if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
469
|
+
return profileIds[0];
|
|
470
|
+
}
|
|
471
|
+
console.log("");
|
|
472
|
+
const picked = await selectOption(
|
|
473
|
+
"Which profile should be active?",
|
|
474
|
+
profileIds.map((id) => ({ label: id })),
|
|
475
|
+
);
|
|
476
|
+
return picked === null ? null : profileIds[picked];
|
|
421
477
|
}
|