magus-cli 7.10.0 → 7.10.1
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__/blind/c15-probe-ownership.test.ts +3 -3
- package/src/__tests__/cli-tools-adapter.test.ts +25 -6
- package/src/__tests__/tool-probe.test.ts +21 -13
- package/src/__tests__/update-plan.test.ts +19 -3
- package/src/cli/update-view.ts +13 -14
- package/src/services/tool-probe.ts +41 -7
- package/src/services/update-plan.ts +9 -4
- package/src/ui/adapters/cliToolsAdapter.ts +15 -6
- package/src/ui/renderers/cliToolRenderers.tsx +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.10.
|
|
3
|
+
"version": "7.10.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.10.
|
|
69
|
-
"magus-cli-darwin-x64": "7.10.
|
|
70
|
-
"magus-cli-linux-x64": "7.10.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.10.1",
|
|
69
|
+
"magus-cli-darwin-x64": "7.10.1",
|
|
70
|
+
"magus-cli-linux-x64": "7.10.1"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -361,11 +361,11 @@ describe("C15 (c) — a crush binary that npm does not own", () => {
|
|
|
361
361
|
expect(facts.managed).not.toBe(true);
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
-
test("R9.7 classifyTool keeps the not-managed veto for it (
|
|
364
|
+
test("R9.7 classifyTool keeps the not-managed veto for it (external / not-managed)", async () => {
|
|
365
365
|
const { facts } = await probe(CRUSH, elsewhere());
|
|
366
366
|
const status = classifyTool(CRUSH, facts);
|
|
367
367
|
expect({ state: status.state, reason: status.reason }).toEqual({
|
|
368
|
-
state: "
|
|
368
|
+
state: "external",
|
|
369
369
|
reason: "not-managed",
|
|
370
370
|
});
|
|
371
371
|
});
|
|
@@ -526,7 +526,7 @@ describe("C15 (e) — a claude binary outside the native layout and outside node
|
|
|
526
526
|
expect(facts.managed).not.toBe(true);
|
|
527
527
|
const status = classifyTool(CLAUDE, facts);
|
|
528
528
|
expect({ state: status.state, reason: status.reason }).toEqual({
|
|
529
|
-
state: "
|
|
529
|
+
state: "external",
|
|
530
530
|
reason: "not-managed",
|
|
531
531
|
});
|
|
532
532
|
});
|
|
@@ -197,7 +197,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
197
197
|
expect(row.installCommand).toEqual(cliToolInstall(codex));
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
test("detected pnpm → the catalogue's via, and a not-managed row
|
|
200
|
+
test("detected pnpm → the catalogue's via, and a not-managed row in the user's terms", () => {
|
|
201
201
|
const opencode = catalogue("opencode");
|
|
202
202
|
const install = { primary: "pnpm" as const, all: ["pnpm" as const] };
|
|
203
203
|
const spec = specForCatalogTool(opencode, null, install);
|
|
@@ -210,8 +210,13 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
210
210
|
owner: "npm (root /Users/j/.local/lib/node_modules)",
|
|
211
211
|
});
|
|
212
212
|
const row = buildToolRow(opencode, spec, f, install);
|
|
213
|
+
expect(row.status?.state).toBe("external");
|
|
213
214
|
expect(row.status?.reason).toBe("not-managed");
|
|
214
|
-
|
|
215
|
+
// No path and no installer directory: the user acts on neither.
|
|
216
|
+
expect(row.status?.note).toBe(
|
|
217
|
+
"installed outside npm, so magus leaves it as is",
|
|
218
|
+
);
|
|
219
|
+
expect(row.status?.note).not.toContain("/");
|
|
215
220
|
// Enter still follows how it IS installed (pnpm).
|
|
216
221
|
expect(row.updateCommand?.cmd).toBe("pnpm");
|
|
217
222
|
});
|
|
@@ -249,16 +254,28 @@ describe("cliToolStatusText — the row's words", () => {
|
|
|
249
254
|
primary: "npm",
|
|
250
255
|
all: ["npm"],
|
|
251
256
|
});
|
|
252
|
-
const f = facts("crush", { managed:
|
|
257
|
+
const f = facts("crush", { managed: null });
|
|
253
258
|
const row = buildToolRow(tool, spec, f);
|
|
254
259
|
const text = cliToolStatusText(row);
|
|
255
260
|
expect(text.tone).toBe("warning");
|
|
256
|
-
expect(text.suffix).toBe(" ? not
|
|
257
|
-
// The note
|
|
261
|
+
expect(text.suffix).toBe(" ? owner not known");
|
|
262
|
+
// The note is for the detail pane's "Why", not the row.
|
|
258
263
|
expect(row.status?.note).toBe(classifyTool(spec, f).note);
|
|
259
264
|
expect(text.suffix).not.toContain("/");
|
|
260
265
|
});
|
|
261
266
|
|
|
267
|
+
test("external renders muted, naming the installer it is outside of", () => {
|
|
268
|
+
const tool = catalogue("crush");
|
|
269
|
+
const spec = specForCatalogTool(tool, null, {
|
|
270
|
+
primary: "npm",
|
|
271
|
+
all: ["npm"],
|
|
272
|
+
});
|
|
273
|
+
const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
|
|
274
|
+
const text = cliToolStatusText(row);
|
|
275
|
+
expect(text.tone).toBe("muted");
|
|
276
|
+
expect(text.suffix).toBe(" · outside npm");
|
|
277
|
+
});
|
|
278
|
+
|
|
262
279
|
test("upgrade names the target once, with one v", () => {
|
|
263
280
|
const tool = catalogue("mnemex");
|
|
264
281
|
const spec = specForCatalogTool(tool, null, {
|
|
@@ -287,12 +304,14 @@ describe("countToolRows — header counts from states", () => {
|
|
|
287
304
|
const rows = [
|
|
288
305
|
buildToolRow(tool, spec, facts("mnemex", { behind: false })),
|
|
289
306
|
buildToolRow(tool, spec, facts("mnemex", { behind: true })),
|
|
307
|
+
buildToolRow(tool, spec, facts("mnemex", { managed: null })),
|
|
308
|
+
// Another installer's: installed, and not "unverified".
|
|
290
309
|
buildToolRow(tool, spec, facts("mnemex", { managed: false })),
|
|
291
310
|
buildToolRow(tool, spec, facts("mnemex", { present: false })),
|
|
292
311
|
buildToolRow(tool, spec, undefined),
|
|
293
312
|
];
|
|
294
313
|
expect(countToolRows(rows)).toEqual({
|
|
295
|
-
installed:
|
|
314
|
+
installed: 4,
|
|
296
315
|
updates: 1,
|
|
297
316
|
unverified: 1,
|
|
298
317
|
});
|
|
@@ -226,7 +226,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
226
226
|
);
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
-
test("6. not managed →
|
|
229
|
+
test("6. not managed → external, in the user's terms", () => {
|
|
230
230
|
const s = classifyTool(
|
|
231
231
|
spec({ name: "crush" }),
|
|
232
232
|
facts({
|
|
@@ -237,16 +237,24 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
237
237
|
owner: "npm (root /Users/j/.local/lib/node_modules)",
|
|
238
238
|
}),
|
|
239
239
|
);
|
|
240
|
-
expect(s).toMatchObject({ state: "
|
|
241
|
-
expect(s.note).
|
|
242
|
-
expect(s.note).toContain("
|
|
243
|
-
expect(s.note).toContain("second copy");
|
|
240
|
+
expect(s).toMatchObject({ state: "external", reason: "not-managed" });
|
|
241
|
+
expect(s.note).toBe("installed outside npm, so magus leaves it as is");
|
|
242
|
+
expect(s.note).not.toContain("/");
|
|
244
243
|
});
|
|
245
244
|
|
|
246
|
-
test("6. managed === null (owner undeterminable) is also a veto", () => {
|
|
245
|
+
test("6. managed === null (owner undeterminable) is also a veto, as unknown", () => {
|
|
247
246
|
expect(
|
|
248
|
-
classifyTool(spec(), facts({ managed: null, behind: true }))
|
|
249
|
-
).
|
|
247
|
+
classifyTool(spec(), facts({ managed: null, behind: true })),
|
|
248
|
+
).toMatchObject({ state: "unknown", reason: "not-managed" });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("6. brew is named as the user knows it", () => {
|
|
252
|
+
expect(
|
|
253
|
+
classifyTool(
|
|
254
|
+
spec({ via: "brew" }),
|
|
255
|
+
facts({ managed: false, behind: true }),
|
|
256
|
+
).note,
|
|
257
|
+
).toBe("installed outside Homebrew, so magus leaves it as is");
|
|
250
258
|
});
|
|
251
259
|
|
|
252
260
|
test("7. held back → unknown, with the hold as the note", () => {
|
|
@@ -364,7 +372,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
364
372
|
];
|
|
365
373
|
for (const [s, f] of cases) {
|
|
366
374
|
const status = classifyTool(spec(s), facts(f));
|
|
367
|
-
expect(["unknown", "upgrade"]).toContain(status.state);
|
|
375
|
+
expect(["unknown", "upgrade", "external"]).toContain(status.state);
|
|
368
376
|
expect(status.note?.length ?? 0).toBeGreaterThan(0);
|
|
369
377
|
}
|
|
370
378
|
});
|
|
@@ -866,11 +874,11 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
866
874
|
const [f] = await probeTools([hostile], io);
|
|
867
875
|
expect(io.calls).toEqual(["npm root -g"]);
|
|
868
876
|
expect(f?.installed).toBeNull();
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
877
|
+
// Another installer's file ⇒ external, so it is reported and never
|
|
878
|
+
// applied. The reason is ownership (R9.9), not the unread version that
|
|
879
|
+
// follows from it.
|
|
872
880
|
expect(classifyTool(hostile, f!)).toMatchObject({
|
|
873
|
-
state: "
|
|
881
|
+
state: "external",
|
|
874
882
|
reason: "not-managed",
|
|
875
883
|
});
|
|
876
884
|
|
|
@@ -502,7 +502,7 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
502
502
|
const p = plan({
|
|
503
503
|
bins: planBinUpdates(
|
|
504
504
|
[{ name: "crush", via: "npm", sources: [] }],
|
|
505
|
-
[toolFacts("crush", { managed:
|
|
505
|
+
[toolFacts("crush", { managed: null })],
|
|
506
506
|
),
|
|
507
507
|
});
|
|
508
508
|
expect(p.bins[0]!.action).toBe("unknown");
|
|
@@ -510,6 +510,19 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
510
510
|
expect(planIsBehind(p)).toBe(false);
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
+
test("an EXTERNAL tool (another installer's) is no work, and not unknown", () => {
|
|
514
|
+
const p = plan({
|
|
515
|
+
bins: planBinUpdates(
|
|
516
|
+
[{ name: "crush", via: "npm", sources: [] }],
|
|
517
|
+
[toolFacts("crush", { managed: false, behind: true })],
|
|
518
|
+
),
|
|
519
|
+
});
|
|
520
|
+
expect(p.bins[0]!.action).toBe("external");
|
|
521
|
+
expect(planHasWork(p)).toBe(false);
|
|
522
|
+
expect(planIsBehind(p)).toBe(false);
|
|
523
|
+
expect(summarizePlan(p)).toMatchObject({ unknownTools: 0, kept: 1 });
|
|
524
|
+
});
|
|
525
|
+
|
|
513
526
|
test("a MISSING binary does fail the gate", () => {
|
|
514
527
|
const p = plan({
|
|
515
528
|
bins: planBinUpdates(
|
|
@@ -946,11 +959,13 @@ describe("planHasWork / summarizePlan", () => {
|
|
|
946
959
|
{ name: "crush", via: "npm", sources: [] },
|
|
947
960
|
{ name: "claude", via: "npm", sources: [] },
|
|
948
961
|
{ name: "tmux", via: "brew", sources: [] },
|
|
962
|
+
{ name: "zsh", via: "brew", sources: [] },
|
|
949
963
|
],
|
|
950
964
|
[
|
|
951
|
-
toolFacts("crush", { managed:
|
|
952
|
-
toolFacts("claude", { managed:
|
|
965
|
+
toolFacts("crush", { managed: null }),
|
|
966
|
+
toolFacts("claude", { managed: null }),
|
|
953
967
|
toolFacts("tmux", { behind: false }),
|
|
968
|
+
toolFacts("zsh", { managed: false }),
|
|
954
969
|
],
|
|
955
970
|
),
|
|
956
971
|
});
|
|
@@ -959,6 +974,7 @@ describe("planHasWork / summarizePlan", () => {
|
|
|
959
974
|
unknownTools: 2,
|
|
960
975
|
unknown: 3,
|
|
961
976
|
current: 1,
|
|
977
|
+
kept: 1,
|
|
962
978
|
});
|
|
963
979
|
});
|
|
964
980
|
});
|
package/src/cli/update-view.ts
CHANGED
|
@@ -90,6 +90,7 @@ type ActionKey =
|
|
|
90
90
|
| "refresh"
|
|
91
91
|
| "stale-load"
|
|
92
92
|
| "remove"
|
|
93
|
+
| "external"
|
|
93
94
|
| "unknown";
|
|
94
95
|
|
|
95
96
|
const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
@@ -111,6 +112,9 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
|
111
112
|
// dead weight Claude Code refuses to load, and the row is work, not a shrug.
|
|
112
113
|
// The plugin table shows the group's DEPRECATED chip, not this label.
|
|
113
114
|
remove: { glyph: "✗", label: "REMOVE", color: brand.danger },
|
|
115
|
+
// A tool another installer put on PATH. Settled, so chrome like CURRENT,
|
|
116
|
+
// and the same word the summary already prints for settled rows.
|
|
117
|
+
external: { glyph: "=", label: "KEPT", color: brand.muted, chrome: true },
|
|
114
118
|
// Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
|
|
115
119
|
// as "broken". The summary line and the TUI already use warning for it.
|
|
116
120
|
unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
|
|
@@ -453,6 +457,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
453
457
|
// plain one reads identically to before.
|
|
454
458
|
item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
|
|
455
459
|
}`;
|
|
460
|
+
case "external":
|
|
456
461
|
case "unknown":
|
|
457
462
|
return `${item.installed ?? "—"}${
|
|
458
463
|
item.note ? ` ${dim(`(${item.note})`)}` : ""
|
|
@@ -927,15 +932,18 @@ function worktreeLabel(names: readonly string[]): string {
|
|
|
927
932
|
* The worktree rows: after a setup (`results`), one `✓` line for every
|
|
928
933
|
* worktree set up and a `✗` line for each failure; on a read-only run
|
|
929
934
|
* (`results` null), one `⚠` line for the ones `magus update` would set up.
|
|
930
|
-
*
|
|
931
|
-
*
|
|
935
|
+
*
|
|
936
|
+
* A worktree that needs a choice gets no line. magus cannot set it up from
|
|
937
|
+
* here, and a line per worktree on every run is noise about folders the user
|
|
938
|
+
* may never open: old branches, agent and bench worktrees. The `setup`
|
|
939
|
+
* plugin says so when Claude Code starts in one, and magus run there asks.
|
|
940
|
+
* Empty when there is nothing to say, blank line included.
|
|
932
941
|
*/
|
|
933
942
|
export function worktreeLines(
|
|
934
943
|
worktrees: readonly UnsetWorktree[],
|
|
935
944
|
results: readonly WorktreeSetupResult[] | null,
|
|
936
945
|
): string[] {
|
|
937
|
-
|
|
938
|
-
const lines = [""];
|
|
946
|
+
const lines: string[] = [];
|
|
939
947
|
if (results === null) {
|
|
940
948
|
const auto = worktrees.filter((w) => w.autoActivate).map((w) => w.name);
|
|
941
949
|
if (auto.length > 0) {
|
|
@@ -965,14 +973,5 @@ export function worktreeLines(
|
|
|
965
973
|
}
|
|
966
974
|
}
|
|
967
975
|
}
|
|
968
|
-
|
|
969
|
-
if (!w.autoActivate) {
|
|
970
|
-
lines.push(
|
|
971
|
-
warn(
|
|
972
|
-
`Worktree ${bold(w.name)} is missing this project's plugins. Run magus update there to choose a profile.`,
|
|
973
|
-
),
|
|
974
|
-
);
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
return lines;
|
|
976
|
+
return lines.length > 0 ? ["", ...lines] : [];
|
|
978
977
|
}
|
|
@@ -58,7 +58,18 @@ import {
|
|
|
58
58
|
|
|
59
59
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* `external` is a determined fact, not a failure to check: the tool is on
|
|
63
|
+
* PATH, and something other than the installer magus would use put it there
|
|
64
|
+
* (bun, an official installer, the OS). magus leaves it as is, so it is
|
|
65
|
+
* neither work nor "could not be checked".
|
|
66
|
+
*/
|
|
67
|
+
export type ToolState =
|
|
68
|
+
| "install"
|
|
69
|
+
| "current"
|
|
70
|
+
| "upgrade"
|
|
71
|
+
| "external"
|
|
72
|
+
| "unknown";
|
|
62
73
|
|
|
63
74
|
export type ToolReason =
|
|
64
75
|
| "absent"
|
|
@@ -215,16 +226,38 @@ function withFailure(text: string, failure: string | undefined): string {
|
|
|
215
226
|
return failure ? `${text} (${failure})` : text;
|
|
216
227
|
}
|
|
217
228
|
|
|
218
|
-
/** The
|
|
229
|
+
/** The installer as the user knows it. */
|
|
230
|
+
export function installerWord(via: ManagedTool["via"]): string {
|
|
231
|
+
return via === "brew" ? "Homebrew" : via;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The `not-managed` sentence, in the user's terms: who did not install it,
|
|
236
|
+
* and what magus does about it. Never the path or the installer's directory:
|
|
237
|
+
* the user acts on neither.
|
|
238
|
+
*/
|
|
219
239
|
function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
|
|
240
|
+
const pin = pinOf(spec);
|
|
241
|
+
if (facts.managed === false && pin) {
|
|
242
|
+
return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
|
|
243
|
+
}
|
|
220
244
|
return facts.managed === false
|
|
221
|
-
? `
|
|
245
|
+
? `installed outside ${installerWord(spec.via)}, so magus leaves it as is`
|
|
222
246
|
: withFailure(
|
|
223
|
-
`could not tell whether ${spec.via}
|
|
247
|
+
`could not tell whether ${installerWord(spec.via)} installed it`,
|
|
224
248
|
facts.failure,
|
|
225
249
|
);
|
|
226
250
|
}
|
|
227
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Another installer's file is `external`, settled, only while nothing is
|
|
254
|
+
* asked of it. With a pin the profile declares a version magus cannot deliver
|
|
255
|
+
* without a second copy, so that is still a warning: `unknown`.
|
|
256
|
+
*/
|
|
257
|
+
function externalOr(pin: string | undefined): ToolState {
|
|
258
|
+
return pin ? "unknown" : "external";
|
|
259
|
+
}
|
|
260
|
+
|
|
228
261
|
/**
|
|
229
262
|
* THE tool classifier. Pure. The first matching rule wins, and each branch
|
|
230
263
|
* returns, so reading top to bottom is reading the precedence.
|
|
@@ -271,7 +304,7 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
271
304
|
if (installed === null && facts.managed === false) {
|
|
272
305
|
return {
|
|
273
306
|
...base,
|
|
274
|
-
state:
|
|
307
|
+
state: externalOr(pin),
|
|
275
308
|
reason: "not-managed",
|
|
276
309
|
target: null,
|
|
277
310
|
note: notManagedNote(spec, facts),
|
|
@@ -317,11 +350,12 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
317
350
|
};
|
|
318
351
|
}
|
|
319
352
|
|
|
320
|
-
// 7. The veto: the upgrade would not replace the file PATH runs.
|
|
353
|
+
// 7. The veto: the upgrade would not replace the file PATH runs. Known to be
|
|
354
|
+
// another installer's file: `external`. Not known either way: `unknown`.
|
|
321
355
|
if (facts.managed !== true) {
|
|
322
356
|
return {
|
|
323
357
|
...base,
|
|
324
|
-
state: "unknown",
|
|
358
|
+
state: facts.managed === false ? externalOr(pin) : "unknown",
|
|
325
359
|
reason: "not-managed",
|
|
326
360
|
target: null,
|
|
327
361
|
note: notManagedNote(spec, facts),
|
|
@@ -1235,8 +1235,10 @@ export function summarizePlan(plan: UpdatePlan): {
|
|
|
1235
1235
|
unknownPlugins: number;
|
|
1236
1236
|
unknownTools: number;
|
|
1237
1237
|
/**
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1238
|
+
* Left as they are, on purpose. Plugins no longer published by their
|
|
1239
|
+
* marketplace and kept (moved, or still required), and tools another
|
|
1240
|
+
* installer put on PATH (`external`). Determined facts, so not part of
|
|
1241
|
+
* `unknown`.
|
|
1240
1242
|
*/
|
|
1241
1243
|
kept: number;
|
|
1242
1244
|
} {
|
|
@@ -1244,8 +1246,11 @@ export function summarizePlan(plan: UpdatePlan): {
|
|
|
1244
1246
|
plan.plugins.filter((p) => p.action === a).length;
|
|
1245
1247
|
const bins = (a: BinUpdateAction) =>
|
|
1246
1248
|
plan.bins.filter((b) => b.action === a).length;
|
|
1247
|
-
const
|
|
1248
|
-
const unknownPlugins = count("unknown") -
|
|
1249
|
+
const keptPlugins = plan.plugins.filter(isKeptDeprecated).length;
|
|
1250
|
+
const unknownPlugins = count("unknown") - keptPlugins;
|
|
1251
|
+
// A tool another installer put on PATH is left as it is: settled, like a
|
|
1252
|
+
// kept plugin, and never "could not be checked".
|
|
1253
|
+
const kept = keptPlugins + bins("external");
|
|
1249
1254
|
const unknownTools = bins("unknown");
|
|
1250
1255
|
return {
|
|
1251
1256
|
installed: count("install") + bins("install"),
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type ToolReason,
|
|
30
30
|
type ToolStatus,
|
|
31
31
|
classifyTool,
|
|
32
|
+
installerWord,
|
|
32
33
|
} from "../../services/tool-probe.js";
|
|
33
34
|
import { binCommandForState } from "../../services/toolchain.js";
|
|
34
35
|
import type { Presence } from "../../shared/plugin/hooks/lib/requires-probe.js";
|
|
@@ -246,7 +247,9 @@ export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
|
|
|
246
247
|
command,
|
|
247
248
|
};
|
|
248
249
|
}
|
|
249
|
-
|
|
250
|
+
// Another installer's file, or one magus could not place: still offered,
|
|
251
|
+
// as an explicit choice, with the note beside it (see the renderer).
|
|
252
|
+
if (status.state === "unknown" || status.state === "external") {
|
|
250
253
|
return {
|
|
251
254
|
kind: "update-anyway",
|
|
252
255
|
label: "update anyway",
|
|
@@ -280,13 +283,14 @@ export interface CliToolStatusText {
|
|
|
280
283
|
* line, the same string `magus update` prints.
|
|
281
284
|
*
|
|
282
285
|
* Every label is at most 19 cells, so the row suffix (" ? " + label) stays
|
|
283
|
-
* within 24. Keyed by reason alone
|
|
284
|
-
* word, never a path.
|
|
286
|
+
* within 24. Keyed by reason alone.
|
|
285
287
|
*/
|
|
286
|
-
function unknownLabel(reason: ToolReason
|
|
288
|
+
function unknownLabel(reason: ToolReason): string {
|
|
287
289
|
switch (reason) {
|
|
290
|
+
// Only the undetermined case: a file known to be another installer's
|
|
291
|
+
// is `external`, not `unknown`.
|
|
288
292
|
case "not-managed":
|
|
289
|
-
return
|
|
293
|
+
return "owner not known";
|
|
290
294
|
case "version-unreadable":
|
|
291
295
|
return "version unreadable";
|
|
292
296
|
case "latest-unavailable":
|
|
@@ -321,9 +325,14 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
|
|
|
321
325
|
switch (status.state) {
|
|
322
326
|
case "unknown":
|
|
323
327
|
return {
|
|
324
|
-
suffix: ` ? ${unknownLabel(status.reason
|
|
328
|
+
suffix: ` ? ${unknownLabel(status.reason)}`,
|
|
325
329
|
tone: "warning",
|
|
326
330
|
};
|
|
331
|
+
case "external":
|
|
332
|
+
return {
|
|
333
|
+
suffix: ` · outside ${installerWord(row.spec.via)}`,
|
|
334
|
+
tone: "muted",
|
|
335
|
+
};
|
|
327
336
|
case "upgrade":
|
|
328
337
|
return {
|
|
329
338
|
suffix: status.target
|
|
@@ -250,6 +250,10 @@ export function renderCliToolRow(
|
|
|
250
250
|
} else if (status?.state === "unknown") {
|
|
251
251
|
icon = "?";
|
|
252
252
|
iconColor = theme.colors.warning;
|
|
253
|
+
} else if (status?.state === "external") {
|
|
254
|
+
// Installed, and left alone on purpose: present, not a finding.
|
|
255
|
+
icon = "●";
|
|
256
|
+
iconColor = theme.colors.muted;
|
|
253
257
|
} else {
|
|
254
258
|
icon = "●";
|
|
255
259
|
iconColor = theme.colors.success;
|
|
@@ -353,6 +357,8 @@ export function renderCliToolDetail(
|
|
|
353
357
|
</text>
|
|
354
358
|
) : state === "upgrade" ? (
|
|
355
359
|
<text fg={theme.colors.warning}>{"● Update available"}</text>
|
|
360
|
+
) : state === "external" ? (
|
|
361
|
+
<text fg={theme.colors.muted}>{"● Installed, kept as is"}</text>
|
|
356
362
|
) : state === "unknown" ? (
|
|
357
363
|
// A version that was read but cannot be checked for updates is
|
|
358
364
|
// still a version: say "installed", and let Why say the rest.
|
|
@@ -365,12 +371,17 @@ export function renderCliToolDetail(
|
|
|
365
371
|
<text fg={theme.colors.success}>{"● Up to date"}</text>
|
|
366
372
|
)}
|
|
367
373
|
</box>
|
|
368
|
-
{state === "unknown" && status?.note ? (
|
|
374
|
+
{(state === "unknown" || state === "external") && status?.note ? (
|
|
369
375
|
<box flexDirection="row">
|
|
370
376
|
<text fg={theme.colors.muted} flexShrink={0}>
|
|
371
377
|
{"Why "}
|
|
372
378
|
</text>
|
|
373
|
-
<text
|
|
379
|
+
<text
|
|
380
|
+
fg={
|
|
381
|
+
state === "external" ? theme.colors.muted : theme.colors.warning
|
|
382
|
+
}
|
|
383
|
+
minWidth={0}
|
|
384
|
+
>
|
|
374
385
|
{wrapValue(status.note, width)}
|
|
375
386
|
</text>
|
|
376
387
|
</box>
|