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.
- 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__/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 +19 -11
- package/src/cli/router.ts +59 -2
- package/src/cli/update-view.ts +236 -45
- package/src/cli/update.ts +9 -22
- package/src/main.tsx +1 -1
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- package/src/services/resolver.ts +33 -0
- package/src/services/update-plan.ts +23 -66
- package/src/ui/App.tsx +0 -3
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/theme-mode.ts +88 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
|
@@ -108,48 +108,58 @@ function cliNote(
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
describe("pluginRowText — a status never depends on a displayable version", () => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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('
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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("
|
|
129
|
-
const { text
|
|
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(
|
|
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
|
-
|
|
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(
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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("
|
|
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
|
-
|
|
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"
|
|
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: "
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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).
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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("
|
|
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).
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
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
|
/**
|
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));
|