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.
Files changed (59) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/mate-availability.test.ts +11 -13
  13. package/src/__tests__/plugin-contents.test.ts +0 -1
  14. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  15. package/src/__tests__/plugin-row-text.test.ts +18 -21
  16. package/src/__tests__/profile-command.test.ts +2 -2
  17. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  18. package/src/__tests__/profile-skills.test.ts +3 -3
  19. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  20. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  21. package/src/__tests__/profile-state.test.ts +27 -22
  22. package/src/__tests__/scope-action.test.ts +35 -38
  23. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  24. package/src/__tests__/select-restore.test.ts +209 -0
  25. package/src/__tests__/stale-load-repair.test.ts +1 -2
  26. package/src/__tests__/update-apply.test.ts +18 -2
  27. package/src/__tests__/update-null-readback.test.ts +300 -0
  28. package/src/__tests__/update-plan-parity.test.ts +249 -0
  29. package/src/__tests__/update-plan.test.ts +66 -67
  30. package/src/__tests__/version-unknown-state.test.ts +38 -39
  31. package/src/cli/bootstrap.ts +239 -66
  32. package/src/cli/doctor.ts +4 -3
  33. package/src/cli/install.ts +83 -27
  34. package/src/cli/prompt.ts +7 -3
  35. package/src/cli/router.ts +1 -0
  36. package/src/cli/select.ts +168 -0
  37. package/src/cli/update-view.ts +118 -6
  38. package/src/cli/update.ts +6 -4
  39. package/src/services/claude-settings.ts +28 -0
  40. package/src/services/manifest.ts +31 -0
  41. package/src/services/mate-availability.ts +23 -29
  42. package/src/services/plugin-manager.ts +247 -105
  43. package/src/services/profile-absorb.ts +10 -7
  44. package/src/services/profile-activate.ts +39 -18
  45. package/src/services/profile-startup.ts +160 -4
  46. package/src/services/profile-state.ts +7 -6
  47. package/src/services/tracked-live-files.ts +24 -3
  48. package/src/services/update-engine.ts +28 -10
  49. package/src/services/update-plan.ts +78 -94
  50. package/src/ui/App.tsx +7 -2
  51. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  52. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  53. package/src/ui/hooks/useProfileStartupModal.ts +87 -7
  54. package/src/ui/keymap.ts +1 -1
  55. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  56. package/src/ui/renderers/pluginRowText.ts +6 -2
  57. package/src/ui/renderers/pluginStatusText.ts +4 -3
  58. package/src/ui/screens/PluginsScreen.tsx +28 -41
  59. package/src/cli/tui-prompt.tsx +0 -143
@@ -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 { dim } from "./ansi.js";
45
- import { confirm } from "./prompt.js";
46
- import { selectDialog } from "./tui-prompt.js";
47
- import { fail as failLine, note as noteLine } from "./update-view.js";
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(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
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(`✓ Active profile: ${result.profileId}`);
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
- /** Print what activation did beyond the switch itself, one line each. */
105
- export function printActivationNotes(result: ActivationResult): void {
106
- for (const line of activationNotes(result)) console.log(noteLine(line));
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
- /** Print what adoption would capture, so the user can judge it before writing. */
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
- "\nThis project has no .claude/profiles.json — magus can create one from its current setup.\n",
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
- ` plugins ${result.counts.plugins} enabled, adopted as "latest"`,
275
+ noteLine(
276
+ "by committing it. Save what this project uses now as the first one.",
277
+ ),
165
278
  );
166
- if (result.source === "user") {
167
- // Never let this be invisible: the generated file is meant to be committed,
168
- // and these came from ~/.claude/settings.json — this machine's personal
169
- // global set, not anything this repo chose.
170
- console.log(
171
- " ⚠ read from ~/.claude/settings.json (user scope) — this project",
172
- );
173
- console.log(
174
- " enables none of its own. Review before committing.",
175
- );
176
- }
177
- if (result.counts.mcpServers > 0) {
178
- console.log(` mcp servers ${result.counts.mcpServers} from .mcp.json`);
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
- if (result.counts.settingsKeys > 0) {
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
- ` settings ${result.counts.settingsKeys} keys carried over`,
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
- ` ⚠ skipped ${id} — no marketplace in its id, not reproducible`,
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
- " Those name a directory on THIS machine and will not resolve on another.",
323
+ warnLine(`A setting points to a folder on this computer: ${hit}`),
196
324
  );
197
325
  }
198
- console.log(
199
- "\nCommit the file to share this setup with the team; edit it to pin versions.",
200
- );
201
- // Said before the question, not after it: adopting also SWITCHES the project
202
- // onto the profile, which replaces two files that are in the working tree
203
- // right now. The prompt below is the only place to decline.
204
- console.log(
205
- "The project then runs this profile: .claude/settings.json and .mcp.json are",
206
- );
207
- console.log(
208
- "generated from it, and your current copies are kept where the run reports.",
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
- `No .claude/profiles.json in ${projectPath}, and no enabled plugins to adopt one from.`,
401
+ failLine(
402
+ `No profile in ${path.basename(projectPath)}, and no plugins to save as one`,
403
+ ),
249
404
  );
250
405
  console.error(
251
- "Open magus and select the plugins this project needs, then re-run.",
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
- "\n--check is read-only and will not create the manifest. Run `magus install` once to adopt.",
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
- "\nRefusing to create .claude/profiles.json without a human: run `magus profile init` to create it deliberately.",
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 (!(await confirm("\nCreate it and switch to it?"))) {
279
- console.log("Aborted — nothing written.");
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
- if (!process.stdin.isTTY) {
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
- const chosen = await selectDialog({
367
- title,
368
- message: describeNeed(need).message,
369
- options: buildProfileOptions(need),
370
- defaultIndex: defaultOptionIndex(need),
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
- // Esc, Ctrl+C and the "Quit magus" row all land here, and there is no third
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 choice = chosen === null ? null : parseProfileChoice(chosen, need);
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 active profile — stopping."));
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(`✓ Active profile: ${result.profileId}`);
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 { trackedGeneratedWarnings } from "../services/profile-activate.js";
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 line of await trackedGeneratedWarnings(projectPath)) {
559
- console.log(`• ${line}`);
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
  }
@@ -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 { confirm, promptValue } from "./prompt.js";
53
- import { note as noteLine } from "./update-view.js";
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 confirm(
123
- `${tc.name} is missing (needed by ${tc.requiredBy.join(", ")}). Install it?`,
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<void> {
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) continue;
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
- console.log(`\nPlan for ${flags.profile ?? "all profiles"}:`);
318
- for (const line of summarizeClosure(closure)) console.log(` ${line}`);
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
- "\n⚠ Version conflicts across profiles (resolved to the higher pin):",
359
+ warnLine("Profiles pin different versions — the higher pin wins"),
322
360
  );
323
- for (const c of closure.conflicts) console.log(` ${c}`);
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 confirm("Apply?"))) {
328
- console.log("Aborted.");
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(`\n✓ Active profile: ${activeId}`);
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
- await withCliBookkeeping(projectPath, () => installPlugins(closure), {
391
- installable: Object.keys(closure.plugins),
392
- report: (line) => console.log(noteLine(line)),
393
- });
394
- await bringLoadsForward(Object.keys(closure.plugins), projectPath);
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) return profileIds[0];
416
- console.log("\nWhich profile should be active?");
417
- profileIds.forEach((id, i) => console.log(` ${i + 1}) ${id}`));
418
- const answer = await promptValue("Choose [1]: ");
419
- const idx = answer ? Number.parseInt(answer, 10) - 1 : 0;
420
- return profileIds[idx] ?? profileIds[0];
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
  }