magus-cli 7.0.0 → 7.1.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.
@@ -108,48 +108,58 @@ function cliNote(
108
108
  }
109
109
 
110
110
  describe("pluginRowText — a status never depends on a displayable version", () => {
111
- test("a sha-versioned unknown carries the CLI's reason verbatim", () => {
112
- // The old row said "? not comparable" here; the CLI said the sentence
113
- // below. Same state, different reason — the R2 violation.
114
- const row = rows([CODE_REVIEW]).get(CODE_REVIEW.id);
115
- const note = cliNote([CODE_REVIEW], CODE_REVIEW.id);
116
- expect(note).toBe(
117
- "its marketplace publishes no version for this plugin, so there is nothing to compare",
118
- );
119
- const { text } = pluginRowText(row as PluginPluginItem);
120
- expect(text).toBe(` v3ea32df27be7 ? ${note}`);
111
+ // A sha install against a catalog that publishes no version is `current`
112
+ // now — we assume what is installed is the latest rather than warning about
113
+ // a comparison that can never be made. R2 still holds and is still the point
114
+ // of this file: whatever the CLI says, the row says, word for word.
115
+ test("a sha-versioned install says exactly what the CLI says", () => {
116
+ const row = rows([CODE_REVIEW]).get(CODE_REVIEW.id) as PluginPluginItem;
117
+ expect(row.status?.action).toBe("current");
118
+ // No note means no suffix. The row is the version and nothing else.
119
+ expect(cliNote([CODE_REVIEW], CODE_REVIEW.id)).toBeUndefined();
120
+ const { text } = pluginRowText(row);
121
+ expect(text).toBe(" v3ea32df27be7");
122
+ // The row never invents a vocabulary the CLI does not use.
121
123
  expect(text).not.toContain("not comparable");
124
+ expect(text).not.toContain("?");
122
125
  });
123
126
 
124
- test('REGRESSION: a "0.0.0" unknown is no longer blank', () => {
125
- // Every old status clause was gated on a displayable version, so this row
126
- // printed nothing after its name and read as healthy.
127
+ test('a "0.0.0" install is never printed as a version', () => {
128
+ // "0.0.0" means installed with an unknown version — Anthropic's official
129
+ // plugins record exactly that. It is not a version anyone can act on, so
130
+ // it is never shown, and no status clause may be gated on showing it.
127
131
  const row = rows([ZERO]).get(ZERO.id) as PluginPluginItem;
128
- expect(row.status?.action).toBe("unknown");
129
- const { text, segments } = pluginRowText(row);
130
- expect(text).toBe(` ? ${cliNote([ZERO], ZERO.id)}`);
131
- // The version itself is still never printed.
132
+ expect(row.status?.action).toBe("current");
133
+ const { text } = pluginRowText(row);
132
134
  expect(text).not.toContain("0.0.0");
133
- expect(segments.at(-1)?.tone).toBe("warning");
135
+ expect(text).toBe(cliNote([ZERO], ZERO.id) ?? "");
134
136
  });
135
137
  });
136
138
 
137
139
  describe("pluginRowText — orphans say what the CLI says (F9)", () => {
138
- test("an orphan that moved names where it went", () => {
140
+ // The row NEVER guesses where an orphan went. That guess was a bare name
141
+ // match, and it cannot tell a move from a dual publication: claudish ships
142
+ // to two marketplaces with both copies installed, and the row announced
143
+ // "moved → magus" about a plugin that had not moved anywhere.
144
+ test("an orphan is 'not offered', and never names a destination", () => {
139
145
  const seo = info({
140
146
  id: "seo@magus",
141
147
  version: "1.8.0",
142
148
  installedVersion: "1.8.0",
143
149
  isOrphaned: true,
144
- movedTo: "magus-marketing",
145
150
  projectScope: { enabled: true, version: "1.8.0" },
146
151
  });
147
152
  const row = rows([seo]).get(seo.id) as PluginPluginItem;
148
- expect(cliNote([seo], seo.id)).toBe("moved to magus-marketing");
149
- expect(pluginRowText(row).text).toBe(" v1.8.0 ? moved to magus-marketing");
153
+ expect(cliNote([seo], seo.id)).toBe(
154
+ "not offered by any configured marketplace",
155
+ );
156
+ expect(pluginRowText(row).text).toBe(
157
+ " v1.8.0 ? not offered by any configured marketplace",
158
+ );
159
+ expect(pluginRowText(row).text).not.toMatch(/moved/i);
150
160
  });
151
161
 
152
- test("an orphan with no movedTo, from a marketplace we read, is 'not offered'", () => {
162
+ test("an orphan from a marketplace we DID read is 'not offered'", () => {
153
163
  const gone = info({
154
164
  id: "gone@magus",
155
165
  version: "2.0.0",
@@ -171,7 +181,6 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
171
181
  id: "seo@magus",
172
182
  installedVersion: "1.8.0",
173
183
  isOrphaned: true,
174
- movedTo: "magus-marketing",
175
184
  projectScope: { enabled: true, version: "1.8.0" },
176
185
  });
177
186
  const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
@@ -278,4 +278,33 @@ describe("main.tsx order", () => {
278
278
  }
279
279
  expect(code).toMatch(/env:\s*themeEnv\b/);
280
280
  });
281
+
282
+ // T-env-3 and T-env-3b guard main.tsx, and main.tsx ALONE — which is exactly
283
+ // how this escaped. The CLI report's theme resolution was added in router.ts
284
+ // and called `snapshotThemeEnv()` there, a second snapshot taken from inside
285
+ // `route()`, long after `loadProjectDotenv()` merged the project's .env into
286
+ // process.env. A checked-out repo could set the palette for every subcommand
287
+ // while both tests above stayed green.
288
+ //
289
+ // The rule generalises: main.tsx owns the snapshot because it is the only
290
+ // code that runs before the dotenv load. Everything downstream is HANDED the
291
+ // value.
292
+ test("T-env-3c: no module downstream of the dotenv load takes its own snapshot", () => {
293
+ const downstream = ["cli/router.ts", "cli/update.ts", "cli/doctor.ts"];
294
+ for (const rel of downstream) {
295
+ const src = stripComments(
296
+ readFileSync(path.join(import.meta.dir, "..", rel), "utf-8"),
297
+ );
298
+ expect({ rel, calls: /snapshotThemeEnv\s*\(/.test(src) }).toEqual({
299
+ rel,
300
+ calls: false,
301
+ });
302
+ }
303
+ });
304
+
305
+ test("T-env-3d: main.tsx hands its snapshot to route()", () => {
306
+ // Without this the router falls back to "no opinion" and every subcommand
307
+ // silently reverts to the dark default on a light terminal.
308
+ expect(code).toMatch(/route\([^)]*themeEnv[^)]*\)/);
309
+ });
281
310
  });
@@ -403,11 +403,19 @@ describe("per-scope decisions — a stale scope is never masked", () => {
403
403
  expect(item.reason).toBe("up-to-date");
404
404
  });
405
405
 
406
- test("an installed plugin whose catalog declares no version is 'unknown'", () => {
407
- // This fixture puts dev@magus IN the catalog with a null version, so it is
408
- // the declares-no-version case, not the withdrawn one — the assertion used
409
- // to say "not offered", which is what conflated them. 278 of the 292
410
- // plugins in claude-plugins-official are in exactly this state.
406
+ // REVERSES an earlier decision, deliberately. This used to be `unknown` with
407
+ // a note reading "publishes no version", which put 278 of the 292 plugins in
408
+ // claude-plugins-official into a warning state that no action could ever
409
+ // clear: the marketplace does not publish versions, so re-running changes
410
+ // nothing and the row stays amber forever.
411
+ //
412
+ // The rule now is: if we cannot compare, assume what is installed is the
413
+ // latest. The machine code still says WHY (`no-published-version`), so the
414
+ // distinction is not lost — it just stops being reported as a problem. Do not
415
+ // "fix" this back without changing that rule first.
416
+ test("a catalog that declares no version leaves the install current", () => {
417
+ // dev@magus is IN the catalog with a null version — the declares-no-version
418
+ // case, not the withdrawn one.
411
419
  const [item] = planPluginUpdates(
412
420
  { "dev@magus": "latest" },
413
421
  catalog(
@@ -418,9 +426,12 @@ describe("per-scope decisions — a stale scope is never masked", () => {
418
426
  }),
419
427
  ),
420
428
  );
421
- expect(item.action).toBe("unknown");
422
- expect(item.note).toContain("publishes no version");
429
+ expect(item.action).toBe("current");
423
430
  expect(item.reason).toBe("no-published-version");
431
+ // No note: there is nothing for the reader to do about it.
432
+ expect(item.note).toBeUndefined();
433
+ // And it is not confused with the withdrawn case, which is the next test.
434
+ expect(item.reason).not.toBe("not-offered");
424
435
  });
425
436
 
426
437
  test("an installed plugin ABSENT from every catalog is 'not offered'", () => {
@@ -919,14 +930,13 @@ describe("planHasWork / summarizePlan", () => {
919
930
  test("unknown splits into plugins and tools, and the total is their sum", () => {
920
931
  const p = plan({
921
932
  plugins: planPluginUpdates(
933
+ // `a@magus` is an ORPHAN — no marketplace offers it — which is the
934
+ // one remaining way a plugin is genuinely `unknown`. A catalog that
935
+ // merely declares no version is `current` now, so using that here
936
+ // would leave `unknownPlugins` at 0 and the sum would hold
937
+ // trivially, testing nothing.
922
938
  { "a@magus": "latest" },
923
- catalog(
924
- info({
925
- id: "a@magus",
926
- version: null,
927
- projectScope: { enabled: true, version: "abc123" },
928
- }),
929
- ),
939
+ catalog(orphan("a@magus", "1.0.0")),
930
940
  ),
931
941
  bins: planBinUpdates(
932
942
  [
@@ -991,19 +1001,24 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
991
1001
  expect(item.scopes).toEqual([]);
992
1002
  });
993
1003
 
994
- test("an orphan another marketplace publishes is 'moved', naming it", () => {
1004
+ // `moved` is GONE, and `movedTo` with it. It was inferred from a bare name
1005
+ // match: a plugin published to two marketplaces — claudish is — reported
1006
+ // "moved → magus" when nothing had moved and both copies were installed.
1007
+ // The inference cannot tell a move from a dual publication, so it is not made
1008
+ // at all. An orphan is `not-offered`, which is a fact rather than a guess.
1009
+ test("an orphan is 'not offered', and never guesses where it went", () => {
995
1010
  const [item] = planPluginUpdates(
996
1011
  { "seo@magus": "latest" },
997
1012
  catalog(
998
- orphan("seo@magus", "1.8.0", { movedTo: "magus-marketing" }),
1013
+ orphan("seo@magus", "1.8.0"),
999
1014
  info({ id: "dev@magus", version: "4.7.0" }),
1000
1015
  ),
1001
1016
  );
1002
1017
  expect(item).toMatchObject({
1003
1018
  action: "unknown",
1004
- reason: "moved",
1005
- note: "moved to magus-marketing",
1019
+ reason: "not-offered",
1006
1020
  });
1021
+ expect(item.note).not.toMatch(/moved/i);
1007
1022
  });
1008
1023
 
1009
1024
  test("an orphan whose marketplace we DID read is 'not offered'", () => {
@@ -1060,9 +1075,17 @@ describe("classifyPlugin — an orphan is never 'current'", () => {
1060
1075
  });
1061
1076
  });
1062
1077
 
1063
- describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)", () => {
1064
- test("REGRESSION: a pinned plugin at an unorderable version used to be current", () => {
1065
- // The "latest" path already refused this; the exact-pin path did not.
1078
+ // REVERSES F3, deliberately. `unorderable` is gone as a state: when two
1079
+ // versions cannot be ordered we assume what is installed IS the latest, rather
1080
+ // than raising a warning nothing can clear.
1081
+ //
1082
+ // F3's premise was that "cannot compare" might hide a plugin that is behind.
1083
+ // True, but the cost was paid on every run by rows that were fine — an install
1084
+ // recorded as a commit sha can never be ordered against a semver pin, so the
1085
+ // warning was permanent and unactionable. Do not restore `unorderable` without
1086
+ // changing that rule first.
1087
+ describe("classifyPlugin — an unorderable version is assumed current", () => {
1088
+ test("a pinned plugin whose install cannot be ordered is current", () => {
1066
1089
  const [item] = planPluginUpdates(
1067
1090
  { "playground@official": "7.3.0" },
1068
1091
  catalog(
@@ -1073,16 +1096,13 @@ describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)
1073
1096
  }),
1074
1097
  ),
1075
1098
  );
1076
- expect(item).toMatchObject({
1077
- action: "unknown",
1078
- reason: "unorderable",
1079
- target: null,
1080
- scopes: [],
1081
- note: "installed version cannot be compared with the published 7.3.0",
1082
- });
1099
+ expect(item.action).toBe("current");
1100
+ expect(item.reason).not.toBe("unorderable");
1101
+ // Nothing is planned against it, which is the part F3 actually protected.
1102
+ expect(item.scopes).toEqual([]);
1083
1103
  });
1084
1104
 
1085
- test("with no published version the note names the pin instead", () => {
1105
+ test("no published version, under an exact pin, is also current", () => {
1086
1106
  const [item] = planPluginUpdates(
1087
1107
  { "playground@official": "7.3.0" },
1088
1108
  catalog(
@@ -1093,11 +1113,14 @@ describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)
1093
1113
  }),
1094
1114
  ),
1095
1115
  );
1096
- expect(item).toMatchObject({
1097
- action: "unknown",
1098
- reason: "unorderable",
1099
- note: "installed version cannot be compared with the pin 7.3.0",
1100
- });
1116
+ expect(item.action).toBe("current");
1117
+ expect(item.note).toBeUndefined();
1118
+ // The exact-pin path reports `up-to-date` here where the "latest" path
1119
+ // reports `no-published-version` — same verdict, different machine code.
1120
+ // Asserted as observed rather than as preferred: nothing renders the two
1121
+ // differently today, and forcing them to agree is a change to the
1122
+ // classifier, not to this test.
1123
+ expect(item.reason).toBe("up-to-date");
1101
1124
  });
1102
1125
 
1103
1126
  test("a scope that IS behind still wins: it is planned, not hidden", () => {
@@ -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
- test("an installed version that cannot be compared is unknown, not current", () => {
251
- // 21 registry rows carry the literal string "unknown" as their version.
252
- // Against a semver catalog they are unorderable, so no scope is provably
253
- // behind — and falling through to `current` claims they are up to date.
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("unknown");
268
- expect(items[0]?.note).toContain("cannot be compared");
269
- expect(items[0]?.reason).toBe("unorderable");
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
- expect(items[0]?.action).toBe("unknown");
339
- expect(items[0]?.note).toContain("publishes no version");
340
- expect(items[0]?.note).not.toContain("not offered");
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 is unknown, never healthy', () => {
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. The TUI row hid every status suffix behind a
371
- // displayable version, so this row rendered with none at all.
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
- * Deliberately much darker than `brand.muted`. At full `muted` a 20-cell `░`
378
- * track reads as hatched texture rather than as emptiness, and it competes with
379
- * the fill for the eye — which is the opposite of "dim the chrome, saturate the
380
- * signal". A track is chrome.
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 = darken(brand.muted, 0.68);
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
- return on(hex, inkOn(hex), ` ${label} `);
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
  /**
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`. Subcommands ignore the flag; only the TUI carries it.
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));