magus-cli 7.0.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +58 -17
- package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
- package/src/__tests__/cli-update-view.test.ts +100 -21
- package/src/__tests__/local-marketplace-scan.test.ts +74 -0
- package/src/__tests__/marketplace-refresh.test.ts +12 -7
- package/src/__tests__/plugin-row-text.test.ts +33 -24
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/__tests__/theme-env.test.ts +29 -0
- package/src/__tests__/update-plan.test.ts +57 -34
- package/src/__tests__/version-unknown-state.test.ts +29 -14
- package/src/cli/ansi.ts +58 -11
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/router.ts +59 -2
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +237 -45
- package/src/cli/update.ts +59 -29
- package/src/main.tsx +11 -2
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/manifest.ts +21 -0
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/resolver.ts +33 -0
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-plan.ts +23 -66
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -3
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +142 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
|
@@ -247,10 +247,16 @@ describe("REVIEW-B regressions — found by the patch-quality panel", () => {
|
|
|
247
247
|
expect(items[0]?.reason).toBe("absent");
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
250
|
+
// REVIEW-B's finding is accepted and its remedy REVERSED. 21 registry rows
|
|
251
|
+
// carry the literal string "unknown" as their version; against a semver
|
|
252
|
+
// catalog they are unorderable, so nothing is provably behind. REVIEW-B
|
|
253
|
+
// concluded that made `current` a false claim.
|
|
254
|
+
//
|
|
255
|
+
// The claim is now explicitly "nothing is KNOWN to be behind", and that is
|
|
256
|
+
// what `current` means here. Raising a warning instead put a permanent amber
|
|
257
|
+
// row in front of the user with no action that could ever clear it, which is
|
|
258
|
+
// a worse falsehood: it says "do something" when there is nothing to do.
|
|
259
|
+
test("an installed version that cannot be compared is assumed current", () => {
|
|
254
260
|
const items = planPluginUpdates(
|
|
255
261
|
{ "playground@official": "latest" },
|
|
256
262
|
new Map([
|
|
@@ -264,9 +270,11 @@ describe("REVIEW-B regressions — found by the patch-quality panel", () => {
|
|
|
264
270
|
],
|
|
265
271
|
]),
|
|
266
272
|
);
|
|
267
|
-
expect(items[0]?.action).toBe("
|
|
268
|
-
expect(items[0]?.
|
|
269
|
-
|
|
273
|
+
expect(items[0]?.action).toBe("current");
|
|
274
|
+
expect(items[0]?.reason).not.toBe("unorderable");
|
|
275
|
+
// The row stays silent: no note, and nothing planned against it.
|
|
276
|
+
expect(items[0]?.note).toBeUndefined();
|
|
277
|
+
expect(items[0]?.scopes).toEqual([]);
|
|
270
278
|
});
|
|
271
279
|
|
|
272
280
|
test("a genuinely current install is still current", () => {
|
|
@@ -335,10 +343,14 @@ describe("installed with no comparable catalog version — two different causes"
|
|
|
335
343
|
],
|
|
336
344
|
]),
|
|
337
345
|
);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
346
|
+
// The distinction this test exists for is between the two REASONS, and it
|
|
347
|
+
// still holds. What changed is the verdict: declaring no version is not a
|
|
348
|
+
// problem, so the row is `current` and says nothing. Being withdrawn IS a
|
|
349
|
+
// problem, so the next test's row is `unknown` and says so.
|
|
341
350
|
expect(items[0]?.reason).toBe("no-published-version");
|
|
351
|
+
expect(items[0]?.reason).not.toBe("not-offered");
|
|
352
|
+
expect(items[0]?.action).toBe("current");
|
|
353
|
+
expect(items[0]?.note).toBeUndefined();
|
|
342
354
|
});
|
|
343
355
|
|
|
344
356
|
test("absent from the catalog entirely IS 'not offered'", () => {
|
|
@@ -363,12 +375,15 @@ describe("installed with no comparable catalog version — two different causes"
|
|
|
363
375
|
});
|
|
364
376
|
});
|
|
365
377
|
|
|
366
|
-
describe('the "0.0.0" registry value
|
|
378
|
+
describe('the "0.0.0" registry value keeps its reason', () => {
|
|
367
379
|
test("installed at 0.0.0 with no published version is no-published-version", () => {
|
|
368
380
|
// Real: explanatory-output-style@claude-plugins-official is recorded as
|
|
369
381
|
// "0.0.0" in the registry for many project paths, and its marketplace
|
|
370
|
-
// publishes no version.
|
|
371
|
-
//
|
|
382
|
+
// publishes no version.
|
|
383
|
+
//
|
|
384
|
+
// "0.0.0" must never silently become a DIFFERENT reason — that is what
|
|
385
|
+
// this pins. The verdict is `current`: there is no published version to
|
|
386
|
+
// be behind, so there is nothing to report and nothing to do.
|
|
372
387
|
const items = planPluginUpdates(
|
|
373
388
|
{ "explanatory-output-style@official": "latest" },
|
|
374
389
|
new Map([
|
|
@@ -382,7 +397,7 @@ describe('the "0.0.0" registry value is unknown, never healthy', () => {
|
|
|
382
397
|
],
|
|
383
398
|
]),
|
|
384
399
|
);
|
|
385
|
-
expect(items[0]?.action).toBe("unknown");
|
|
386
400
|
expect(items[0]?.reason).toBe("no-published-version");
|
|
401
|
+
expect(items[0]?.action).toBe("current");
|
|
387
402
|
});
|
|
388
403
|
});
|
package/src/cli/ansi.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* No OpenTUI type crosses this boundary: hex in, escaped string out.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { barTrack } from "../ui/theme-mode.js";
|
|
24
25
|
import { brand } from "../ui/theme.js";
|
|
25
26
|
|
|
26
27
|
// -- colour capability -------------------------------------------------------
|
|
@@ -372,14 +373,17 @@ const FILL = "█";
|
|
|
372
373
|
const TRACK = "░";
|
|
373
374
|
|
|
374
375
|
/**
|
|
375
|
-
* The unfilled part of a bar.
|
|
376
|
+
* The unfilled part of a bar, resolved per page.
|
|
376
377
|
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
378
|
+
* At full `brand.muted` a 20-cell `░` track reads as hatched texture rather
|
|
379
|
+
* than as emptiness, and competes with the fill — the opposite of "dim the
|
|
380
|
+
* chrome, saturate the signal". A track is chrome.
|
|
381
|
+
*
|
|
382
|
+
* Which grey is quiet depends on the page, so the two values and the numbers
|
|
383
|
+
* behind them live in `ui/theme-mode.ts`. Resolved per CALL, never hoisted into
|
|
384
|
+
* a const: the mode is set at startup by the router, after this module loads.
|
|
381
385
|
*/
|
|
382
|
-
const TRACK_COLOR =
|
|
386
|
+
const TRACK_COLOR = (): string => barTrack();
|
|
383
387
|
|
|
384
388
|
/**
|
|
385
389
|
* Gradient meter: `cells` wide, one blended colour per filled cell.
|
|
@@ -402,7 +406,7 @@ export function meter(
|
|
|
402
406
|
);
|
|
403
407
|
let out = "";
|
|
404
408
|
for (let i = 0; i < cells; i++) {
|
|
405
|
-
out += i < filled ? fg(cols[i], FILL) : fg(TRACK_COLOR, TRACK);
|
|
409
|
+
out += i < filled ? fg(cols[i], FILL) : fg(TRACK_COLOR(), TRACK);
|
|
406
410
|
}
|
|
407
411
|
return out;
|
|
408
412
|
}
|
|
@@ -433,7 +437,7 @@ export interface BarSegment {
|
|
|
433
437
|
export function stackedBar(cells: number, segments: BarSegment[]): string {
|
|
434
438
|
if (cells <= 0) return "";
|
|
435
439
|
let live = segments.filter((s) => s.value > 0);
|
|
436
|
-
if (live.length === 0) return fg(TRACK_COLOR, TRACK.repeat(cells));
|
|
440
|
+
if (live.length === 0) return fg(TRACK_COLOR(), TRACK.repeat(cells));
|
|
437
441
|
// More segments than cells: keep the biggest `cells` of them, in place.
|
|
438
442
|
if (live.length > cells) {
|
|
439
443
|
const cutoff = [...live]
|
|
@@ -488,15 +492,19 @@ export function sweep(
|
|
|
488
492
|
const heat = behind >= 0 && behind < band ? 1 - behind / band : 0;
|
|
489
493
|
out +=
|
|
490
494
|
heat <= 0
|
|
491
|
-
? fg(TRACK_COLOR, TRACK)
|
|
495
|
+
? fg(TRACK_COLOR(), TRACK)
|
|
492
496
|
: fg(mix(darken(hue, 0.75), hue, heat), FILL);
|
|
493
497
|
}
|
|
494
498
|
return out;
|
|
495
499
|
}
|
|
496
500
|
|
|
497
501
|
/** A discrete status, as dark-or-white ink on a saturated fill. */
|
|
498
|
-
export function badge(label: string, hex: string): string {
|
|
499
|
-
|
|
502
|
+
export function badge(label: string, hex: string, ink?: string): string {
|
|
503
|
+
// `inkOn` picks black or white by luminance, which is right for a saturated
|
|
504
|
+
// fill and blunt for a pale one: a soft ochre chip reads better with the deep
|
|
505
|
+
// member of its own family than with flat black. A caller carrying a measured
|
|
506
|
+
// pair passes it; everyone else keeps the automatic choice.
|
|
507
|
+
return on(hex, ink ?? inkOn(hex), ` ${label} `);
|
|
500
508
|
}
|
|
501
509
|
|
|
502
510
|
/**
|
|
@@ -505,6 +513,45 @@ export function badge(label: string, hex: string): string {
|
|
|
505
513
|
*/
|
|
506
514
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
507
515
|
|
|
516
|
+
/**
|
|
517
|
+
* A bordered dialog: title in the top rule, body inside, one blank row of
|
|
518
|
+
* breathing space top and bottom.
|
|
519
|
+
*
|
|
520
|
+
* Built on `padEnd`, which measures visible cells, so a body line carrying
|
|
521
|
+
* colour still aligns — padding a coloured string by `String.length` counts the
|
|
522
|
+
* escape bytes and shears the right border by however many bytes the colour
|
|
523
|
+
* cost.
|
|
524
|
+
*
|
|
525
|
+
* The border and the rule are dim and the body is not: a frame that competes
|
|
526
|
+
* with its own contents is why a boxed message can read as louder but say less.
|
|
527
|
+
*/
|
|
528
|
+
export function dialog(
|
|
529
|
+
title: string,
|
|
530
|
+
lines: string[],
|
|
531
|
+
opts: { width?: number; color?: string } = {},
|
|
532
|
+
): string[] {
|
|
533
|
+
const inner = Math.max(
|
|
534
|
+
opts.width ?? 0,
|
|
535
|
+
...lines.map((l) => width(l)),
|
|
536
|
+
width(title) + 4,
|
|
537
|
+
);
|
|
538
|
+
const accent = opts.color;
|
|
539
|
+
const paint = (s: string) => (accent ? fg(accent, s) : s);
|
|
540
|
+
|
|
541
|
+
// Every row must be `inner + 4` cells: corner, space, body, space, corner.
|
|
542
|
+
// The head spends 2 on "╭─" and `width(title) + 2` on the titled gap, so the
|
|
543
|
+
// rule takes the rest. Getting this off by one is invisible in source and
|
|
544
|
+
// obvious on screen — the right border jags one column in at the title row.
|
|
545
|
+
const head = `${paint("╭─")}${paint(` ${title} `)}${dim(
|
|
546
|
+
"─".repeat(Math.max(0, inner - width(title) - 1)),
|
|
547
|
+
)}${dim("╮")}`;
|
|
548
|
+
const foot = dim(`╰${"─".repeat(inner + 2)}╯`);
|
|
549
|
+
const row = (text: string) =>
|
|
550
|
+
`${dim("│")} ${padEnd(text, inner)} ${dim("│")}`;
|
|
551
|
+
|
|
552
|
+
return [head, row(""), ...lines.map(row), row(""), foot];
|
|
553
|
+
}
|
|
554
|
+
|
|
508
555
|
export function spinner(tick: number): string {
|
|
509
556
|
return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length];
|
|
510
557
|
}
|
package/src/cli/bootstrap.ts
CHANGED
|
@@ -8,17 +8,74 @@
|
|
|
8
8
|
* stands, then the original command proceeds against it normally.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import path from "node:path";
|
|
11
12
|
import {
|
|
12
13
|
getManifestPath,
|
|
13
14
|
readManifest,
|
|
14
15
|
writeManifest,
|
|
15
16
|
} from "../services/manifest.js";
|
|
17
|
+
import {
|
|
18
|
+
applyProfileChoice,
|
|
19
|
+
materializeAndActivate,
|
|
20
|
+
reportDisplaced,
|
|
21
|
+
} from "../services/profile-activate.js";
|
|
16
22
|
import {
|
|
17
23
|
type AdoptResult,
|
|
18
24
|
adoptCurrentProject,
|
|
19
25
|
} from "../services/profile-adopt.js";
|
|
26
|
+
import {
|
|
27
|
+
buildProfileOptions,
|
|
28
|
+
defaultOptionIndex,
|
|
29
|
+
describeNeed,
|
|
30
|
+
detectProfileStartupNeed,
|
|
31
|
+
needsNothing,
|
|
32
|
+
parseProfileChoice,
|
|
33
|
+
} from "../services/profile-startup.js";
|
|
20
34
|
import type { ProfileManifest } from "../types/index.js";
|
|
35
|
+
import { dim } from "./ansi.js";
|
|
21
36
|
import { confirm } from "./prompt.js";
|
|
37
|
+
import { selectDialog } from "./tui-prompt.js";
|
|
38
|
+
import { fail as failLine, note as noteLine } from "./update-view.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write the adopted manifest, then put the project ON the profile it describes.
|
|
42
|
+
*
|
|
43
|
+
* Both halves or neither. Writing alone is what made migrating feel like a
|
|
44
|
+
* no-op: `activeProfile()` is derived from the symlink, so a manifest nothing
|
|
45
|
+
* links to changes no behaviour, and the run that just migrated still has to
|
|
46
|
+
* ask which profile to use.
|
|
47
|
+
*
|
|
48
|
+
* Activation is reported in full, including anything it moved aside, because it
|
|
49
|
+
* replaces files the user wrote by hand. A migration is allowed to change the
|
|
50
|
+
* working tree; it is not allowed to do it quietly.
|
|
51
|
+
*/
|
|
52
|
+
async function writeAndActivate(
|
|
53
|
+
result: AdoptResult,
|
|
54
|
+
projectPath: string,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
// Relative to the project throughout. These are all project paths, and an
|
|
57
|
+
// absolute one wraps over two terminal lines — several wrapped lines in a row
|
|
58
|
+
// is where a reader stops reading, and this notice is the one that says where
|
|
59
|
+
// their hand-written files went.
|
|
60
|
+
const rel = (p: string) => path.relative(projectPath, p);
|
|
61
|
+
|
|
62
|
+
await writeManifest(result.manifest, projectPath);
|
|
63
|
+
console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
|
|
64
|
+
|
|
65
|
+
// Each move is printed as it happens, not from the returned list afterwards.
|
|
66
|
+
// Activation touches up to four artifacts in sequence, so a throw on the
|
|
67
|
+
// third would discard the record of the first two — and the first of those
|
|
68
|
+
// is the user's `.claude/settings.json`, now absent from where they left it.
|
|
69
|
+
// Printing first means a failed activation still says where everything went.
|
|
70
|
+
await materializeAndActivate(
|
|
71
|
+
result.manifest,
|
|
72
|
+
result.profileId,
|
|
73
|
+
projectPath,
|
|
74
|
+
reportDisplaced(projectPath),
|
|
75
|
+
);
|
|
76
|
+
console.log(`✓ Active profile: ${result.profileId}`);
|
|
77
|
+
console.log();
|
|
78
|
+
}
|
|
22
79
|
|
|
23
80
|
/** Print what adoption would capture, so the user can judge it before writing. */
|
|
24
81
|
function printAdoptionPlan(result: AdoptResult): void {
|
|
@@ -64,6 +121,16 @@ function printAdoptionPlan(result: AdoptResult): void {
|
|
|
64
121
|
console.log(
|
|
65
122
|
"\nCommit the file to share this setup with the team; edit it to pin versions.",
|
|
66
123
|
);
|
|
124
|
+
// Said before the question, not after it: adopting also SWITCHES the project
|
|
125
|
+
// onto the profile, which replaces two files that are in the working tree
|
|
126
|
+
// right now. The prompt below is the only place to decline.
|
|
127
|
+
console.log(
|
|
128
|
+
"The project then runs this profile: .claude/settings.json, .mcp.json and",
|
|
129
|
+
);
|
|
130
|
+
console.log(
|
|
131
|
+
".claude/skills become links into it, and your current copies are kept where",
|
|
132
|
+
);
|
|
133
|
+
console.log("the run reports.");
|
|
67
134
|
}
|
|
68
135
|
|
|
69
136
|
export interface EnsureManifestOptions {
|
|
@@ -123,12 +190,111 @@ export async function ensureManifest(
|
|
|
123
190
|
"\nRefusing to create .claude/profiles.json without a human: run `magus profile init` to create it deliberately.",
|
|
124
191
|
);
|
|
125
192
|
return null;
|
|
126
|
-
} else if (!(await confirm("\nCreate
|
|
193
|
+
} else if (!(await confirm("\nCreate it and switch to it?"))) {
|
|
127
194
|
console.log("Aborted — nothing written.");
|
|
128
195
|
return null;
|
|
129
196
|
}
|
|
130
197
|
|
|
131
|
-
await
|
|
132
|
-
console.log(`✓ Wrote ${getManifestPath(projectPath)}\n`);
|
|
198
|
+
await writeAndActivate(result, projectPath);
|
|
133
199
|
return result.manifest;
|
|
134
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Require an active profile, or stop the command.
|
|
204
|
+
*
|
|
205
|
+
* magus manages a project THROUGH its active profile: that is where plugin
|
|
206
|
+
* state is read from and where updates are written. With none there is nothing
|
|
207
|
+
* to read and nowhere to write, so there is no useful work to do — and the
|
|
208
|
+
* previous version of this offered exactly that anyway. It asked only when the
|
|
209
|
+
* manifest had drifted, let the user decline, and `update` then carried on with
|
|
210
|
+
* a profile "for this run only", resolving against a manifest the project was
|
|
211
|
+
* not running. Two worlds live at once, which is the confusion being removed.
|
|
212
|
+
*
|
|
213
|
+
* So this asks whenever no profile is active, and the only answers are migrate,
|
|
214
|
+
* activate, or stop. Returns the manifest to carry on with, or null when the
|
|
215
|
+
* command must exit non-zero — by which point it has printed why.
|
|
216
|
+
*
|
|
217
|
+
* Nothing is compared. An earlier version weighed the manifest against
|
|
218
|
+
* `enabledPlugins`; see the header of `services/profile-startup.ts` for why
|
|
219
|
+
* that reads a profiled project's own generated output and cannot be acted on.
|
|
220
|
+
*/
|
|
221
|
+
export async function requireActiveProfile(
|
|
222
|
+
projectPath: string,
|
|
223
|
+
manifest: ProfileManifest,
|
|
224
|
+
opts: EnsureManifestOptions = {},
|
|
225
|
+
): Promise<ProfileManifest | null> {
|
|
226
|
+
const need = await detectProfileStartupNeed(projectPath);
|
|
227
|
+
if (needsNothing(need)) return manifest;
|
|
228
|
+
|
|
229
|
+
// The repo declares which profile it wants and there is nothing of the
|
|
230
|
+
// user's to replace — a fresh clone. Activate it and carry on, even under
|
|
231
|
+
// `--check` and with no TTY: this is the committed answer being applied, not
|
|
232
|
+
// a decision being made, and it is exactly the case CI hits.
|
|
233
|
+
if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
|
|
234
|
+
const { profileId } = await applyProfileChoice(
|
|
235
|
+
{ kind: "activate", id: need.declaredDefault },
|
|
236
|
+
need.manifest,
|
|
237
|
+
projectPath,
|
|
238
|
+
reportDisplaced(projectPath),
|
|
239
|
+
);
|
|
240
|
+
console.log(
|
|
241
|
+
`✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
|
|
242
|
+
);
|
|
243
|
+
console.log("");
|
|
244
|
+
return readManifest(projectPath);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { title } = describeNeed(need);
|
|
248
|
+
|
|
249
|
+
// `--check` is a read-only CI gate, and "this project is not on a profile"
|
|
250
|
+
// is a legitimate thing for it to fail on rather than quietly repair.
|
|
251
|
+
if (opts.check) {
|
|
252
|
+
console.error(failLine(title));
|
|
253
|
+
console.error(noteLine("Run `magus update` at a terminal to resolve it."));
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// CI has nobody to ask. Exiting beats hanging on an answer that cannot come.
|
|
258
|
+
if (!process.stdin.isTTY) {
|
|
259
|
+
console.error(failLine(title));
|
|
260
|
+
console.error(
|
|
261
|
+
noteLine(
|
|
262
|
+
need.state === "unmigrated"
|
|
263
|
+
? "Run `magus profile init` to create one deliberately."
|
|
264
|
+
: `Run \`magus profile switch <${need.activate
|
|
265
|
+
.map((p) => p.id)
|
|
266
|
+
.join("|")}>\` first.`,
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log("");
|
|
273
|
+
const chosen = await selectDialog({
|
|
274
|
+
title,
|
|
275
|
+
message: describeNeed(need).message,
|
|
276
|
+
options: buildProfileOptions(need),
|
|
277
|
+
defaultIndex: defaultOptionIndex(need),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Esc, Ctrl+C and the "Quit magus" row all land here, and there is no third
|
|
281
|
+
// outcome: the command stops rather than proceed without a profile.
|
|
282
|
+
const choice = chosen === null ? null : parseProfileChoice(chosen, need);
|
|
283
|
+
if (!choice) {
|
|
284
|
+
console.error(failLine("No active profile — stopping."));
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const { profileId } = await applyProfileChoice(
|
|
289
|
+
choice,
|
|
290
|
+
need.manifest,
|
|
291
|
+
projectPath,
|
|
292
|
+
reportDisplaced(projectPath),
|
|
293
|
+
);
|
|
294
|
+
console.log(`✓ Active profile: ${profileId}`);
|
|
295
|
+
console.log("");
|
|
296
|
+
|
|
297
|
+
// Re-read rather than return `need.manifest`: adopting wrote a new profile
|
|
298
|
+
// into the file, and the caller resolves against whatever comes back.
|
|
299
|
+
return readManifest(projectPath);
|
|
300
|
+
}
|
package/src/cli/install.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "../services/install-plan.js";
|
|
28
28
|
import { validateManifest } from "../services/manifest.js";
|
|
29
29
|
import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
|
|
30
|
+
import { reportDisplaced } from "../services/profile-activate.js";
|
|
30
31
|
import { materializeProfile } from "../services/profile-materializer.js";
|
|
31
32
|
import { detectProfileDrift } from "../services/profile-sync.js";
|
|
32
33
|
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
@@ -355,7 +356,10 @@ export async function runInstallCommand(
|
|
|
355
356
|
const activeId =
|
|
356
357
|
flags.profile ?? (await pickActiveProfile(profileIds, flags.yes));
|
|
357
358
|
if (activeId) {
|
|
358
|
-
|
|
359
|
+
// The reporter is not optional here. The first `install` in a project that
|
|
360
|
+
// had a hand-written .claude/settings.json moves it aside, and without
|
|
361
|
+
// this the move is invisible — the file is simply not where it was.
|
|
362
|
+
await activateProfile(activeId, projectPath, reportDisplaced(projectPath));
|
|
359
363
|
console.log(`\n✓ Active profile: ${activeId}`);
|
|
360
364
|
// Skills route through the now-active .claude/skills symlink.
|
|
361
365
|
const activeClosure = await resolveProfile(manifest, activeId);
|
package/src/cli/profile.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { ensureGitignoreEntries } from "../services/conventions-manager.js";
|
|
|
13
13
|
import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
|
|
14
14
|
import { summarizeClosure } from "../services/install-plan.js";
|
|
15
15
|
import { getManifestPath, readManifest } from "../services/manifest.js";
|
|
16
|
+
import { reportDisplaced } from "../services/profile-activate.js";
|
|
16
17
|
import { syncProfile } from "../services/profile-sync.js";
|
|
17
18
|
import { resolveProfile } from "../services/resolver.js";
|
|
18
19
|
import {
|
|
@@ -147,7 +148,10 @@ export async function switchProfile(
|
|
|
147
148
|
"# magus: generated profile build output + active-profile symlinks",
|
|
148
149
|
);
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
// Switching between two already-active profiles displaces nothing — the paths
|
|
152
|
+
// are links magus owns. The FIRST switch in an unmanaged project displaces
|
|
153
|
+
// real files, and that is the run that must say so.
|
|
154
|
+
await activateProfile(name, projectPath, reportDisplaced(projectPath));
|
|
151
155
|
console.log(`✓ Switched to profile: ${name}`);
|
|
152
156
|
|
|
153
157
|
// Verify the profile's binary deps are actually usable — the switch itself
|
package/src/cli/prompt.ts
CHANGED
|
@@ -8,25 +8,57 @@
|
|
|
8
8
|
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Ctrl+C at a prompt.
|
|
13
|
+
*
|
|
14
|
+
* `readline/promises` rejects `question()` with an `AbortError` carrying
|
|
15
|
+
* `code: "ABORT_ERR"`. That is a Node internal, and letting it escape printed
|
|
16
|
+
* nine frames of `internal:repl/node-errors` under "Error starting magus:" —
|
|
17
|
+
* a stack trace for the one input every terminal user knows how to give.
|
|
18
|
+
*
|
|
19
|
+
* It is converted here rather than at each call site so no future prompt can
|
|
20
|
+
* forget, and it stays a throw rather than a sentinel return so a caller that
|
|
21
|
+
* ignores it aborts instead of proceeding with a missing answer.
|
|
22
|
+
*/
|
|
23
|
+
export class PromptAborted extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super("Aborted with Ctrl+C");
|
|
26
|
+
this.name = "PromptAborted";
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
30
|
+
/** True for a Ctrl+C at a prompt, ours or readline's own. */
|
|
31
|
+
export function isPromptAborted(error: unknown): boolean {
|
|
32
|
+
if (error instanceof PromptAborted) return true;
|
|
33
|
+
// Belt and braces: an abort raised somewhere that does not route through
|
|
34
|
+
// `ask` still reads as a cancellation rather than a crash.
|
|
35
|
+
return (
|
|
36
|
+
typeof error === "object" &&
|
|
37
|
+
error !== null &&
|
|
38
|
+
(error as { code?: unknown }).code === "ABORT_ERR"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One readline question, with Ctrl+C normalised. */
|
|
43
|
+
async function ask(question: string): Promise<string> {
|
|
26
44
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
27
45
|
try {
|
|
28
46
|
return (await rl.question(question)).trim();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (isPromptAborted(error)) throw new PromptAborted();
|
|
49
|
+
throw error;
|
|
29
50
|
} finally {
|
|
30
51
|
rl.close();
|
|
31
52
|
}
|
|
32
53
|
}
|
|
54
|
+
|
|
55
|
+
/** Y/n prompt. Empty input means yes. */
|
|
56
|
+
export async function confirm(question: string): Promise<boolean> {
|
|
57
|
+
const answer = (await ask(`${question} [Y/n] `)).toLowerCase();
|
|
58
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Free-text prompt, trimmed. */
|
|
62
|
+
export async function promptValue(question: string): Promise<string> {
|
|
63
|
+
return ask(question);
|
|
64
|
+
}
|
package/src/cli/router.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ThemeMode } from "@opentui/core";
|
|
10
10
|
import { checkForUpdates } from "../services/version-check.js";
|
|
11
|
-
import { parseThemeWord } from "../ui/theme-resolve.js";
|
|
11
|
+
import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
|
|
12
12
|
import { runDoctorCommand } from "./doctor.js";
|
|
13
13
|
import { runInstallCommand } from "./install.js";
|
|
14
14
|
import { runModelsCommand } from "./models.js";
|
|
@@ -84,9 +84,32 @@ const HANDLED = (exitCode: number): RouteOutcome => ({
|
|
|
84
84
|
});
|
|
85
85
|
const LAUNCH_TUI: RouteOutcome = { handled: false, launchTui: true };
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Every name the switch below handles.
|
|
89
|
+
*
|
|
90
|
+
* Declared once so the theme resolution and the dispatch cannot disagree about
|
|
91
|
+
* what counts as a subcommand — a name in one and not the other would print an
|
|
92
|
+
* unthemed report, which is the bug this set exists to prevent.
|
|
93
|
+
*/
|
|
94
|
+
const SUBCOMMANDS = new Set([
|
|
95
|
+
"update",
|
|
96
|
+
"upgrade",
|
|
97
|
+
"install",
|
|
98
|
+
"profile",
|
|
99
|
+
"models",
|
|
100
|
+
"doctor",
|
|
101
|
+
]);
|
|
102
|
+
|
|
87
103
|
export async function route(
|
|
88
104
|
argv: string[],
|
|
89
105
|
version: string,
|
|
106
|
+
/**
|
|
107
|
+
* The theme environment, snapshotted by main.tsx BEFORE it loads a project's
|
|
108
|
+
* .env. Optional only so a test may call `route` without one; production
|
|
109
|
+
* always passes it, and omitting it falls back to "no opinion" (dark) rather
|
|
110
|
+
* than reading a process.env that a project may by then have written into.
|
|
111
|
+
*/
|
|
112
|
+
themeEnv?: ThemeEnv,
|
|
90
113
|
): Promise<RouteOutcome> {
|
|
91
114
|
// Top-level flags take precedence over any command token.
|
|
92
115
|
if (argv.includes("--version") || argv.includes("-v")) {
|
|
@@ -111,9 +134,43 @@ export async function route(
|
|
|
111
134
|
}
|
|
112
135
|
|
|
113
136
|
// Dispatch on argv with the theme tokens removed, so `--theme dark update`
|
|
114
|
-
// still runs `update`.
|
|
137
|
+
// still runs `update`.
|
|
115
138
|
const [command, ...rest] = themeFlag.rest;
|
|
116
139
|
|
|
140
|
+
// Every subcommand paints, and until now only the TUI ever asked which page it
|
|
141
|
+
// was painting on: `setThemeMode` was called in tui.tsx alone, so `magus
|
|
142
|
+
// update` kept the "dark" default and printed dark-page fills on a cream
|
|
143
|
+
// terminal. That is why the report read harsh next to the Models screen.
|
|
144
|
+
//
|
|
145
|
+
// No probe here. There is no renderer to ask, and a report must not block on a
|
|
146
|
+
// terminal reply — so this resolves from the flag, MAGUS_THEME, TERM_THEME
|
|
147
|
+
// and COLORFGBG, and still falls back to dark when nothing answers, exactly as
|
|
148
|
+
// before. The TUI keeps its own probing resolution in tui.tsx.
|
|
149
|
+
//
|
|
150
|
+
// `themeEnv` is HANDED IN, never re-snapshotted here. main.tsx takes it as
|
|
151
|
+
// its very first act, before `loadProjectDotenv()` merges a project's .env
|
|
152
|
+
// into process.env — so the theme comes from the environment magus was
|
|
153
|
+
// started with and never from the project being inspected. Calling
|
|
154
|
+
// `snapshotThemeEnv()` at this point would run after that merge and quietly
|
|
155
|
+
// hand a checked-out repo control of the palette.
|
|
156
|
+
if (command !== undefined && SUBCOMMANDS.has(command)) {
|
|
157
|
+
const [{ resolveTheme }, { setThemeMode }] = await Promise.all([
|
|
158
|
+
import("../ui/theme-resolve.js"),
|
|
159
|
+
import("../ui/theme-mode.js"),
|
|
160
|
+
]);
|
|
161
|
+
const { mode } = await resolveTheme({
|
|
162
|
+
flag: themeFlag.theme,
|
|
163
|
+
env: themeEnv ?? {},
|
|
164
|
+
// A report is not an interactive session: it may be piped, redirected
|
|
165
|
+
// or read by CI. `isInteractive: false` is what stops the probe being
|
|
166
|
+
// consulted at all, and the null probe satisfies the signature without
|
|
167
|
+
// ever writing an OSC query into someone's scrollback.
|
|
168
|
+
isInteractive: false,
|
|
169
|
+
probe: async () => null,
|
|
170
|
+
});
|
|
171
|
+
setThemeMode(mode);
|
|
172
|
+
}
|
|
173
|
+
|
|
117
174
|
switch (command) {
|
|
118
175
|
case "update":
|
|
119
176
|
return HANDLED(await runUpdateCommand(rest));
|