magus-cli 7.7.0 → 7.8.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 +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +90 -146
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +282 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +65 -54
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +313 -221
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/version-snapshot.ts +0 -189
package/src/cli/doctor.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getProjectInstalledPluginVersions,
|
|
29
29
|
readGlobalSettings,
|
|
30
30
|
readInstalledPluginsRegistry,
|
|
31
|
+
readLocalSettings,
|
|
31
32
|
readSettings,
|
|
32
33
|
} from "../services/claude-settings.js";
|
|
33
34
|
import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
|
|
@@ -44,7 +45,9 @@ import {
|
|
|
44
45
|
import { isBehind } from "../services/plugin-manager.js";
|
|
45
46
|
import {
|
|
46
47
|
type RepoRootCache,
|
|
48
|
+
isRelevantInstallation,
|
|
47
49
|
pickLoadedEntry,
|
|
50
|
+
repoRootForPath,
|
|
48
51
|
} from "../services/plugin-resolution.js";
|
|
49
52
|
import { trackedGeneratedNotices } from "../services/profile-activate.js";
|
|
50
53
|
import {
|
|
@@ -69,6 +72,7 @@ import {
|
|
|
69
72
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
70
73
|
import type {
|
|
71
74
|
ClaudeSettings,
|
|
75
|
+
InstalledPluginEntry,
|
|
72
76
|
InstalledPluginsRegistry,
|
|
73
77
|
ProfileManifest,
|
|
74
78
|
} from "../types/index.js";
|
|
@@ -271,37 +275,140 @@ async function checkPluginLoads(projectPath: string): Promise<number> {
|
|
|
271
275
|
reportUnparseable("Plugin loads", error);
|
|
272
276
|
return 1;
|
|
273
277
|
}
|
|
278
|
+
let local: ClaudeSettings;
|
|
279
|
+
try {
|
|
280
|
+
local = await readLocalSettings(projectPath);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
283
|
+
reportUnparseable("Plugin loads", error);
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
// The global file's parse failure is section 0's finding, reported there;
|
|
287
|
+
// here it only means the user scope turns nothing on that we can see.
|
|
288
|
+
let global: ClaudeSettings = {};
|
|
289
|
+
try {
|
|
290
|
+
global = await readGlobalSettings();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
293
|
+
}
|
|
294
|
+
const enabled = enabledHere(global, settings, local);
|
|
274
295
|
const installed = await getProjectInstalledPluginVersions(
|
|
275
296
|
settings.installedPluginVersions || {},
|
|
276
297
|
projectPath,
|
|
277
298
|
);
|
|
278
|
-
|
|
299
|
+
// Counted as installed only when claimed HERE — the same rule
|
|
300
|
+
// `findPluginLoadProblems` applies to a plugin that loads nothing.
|
|
301
|
+
const cache: RepoRootCache = new Map();
|
|
302
|
+
const ids = Object.keys(installed).filter((id) =>
|
|
303
|
+
isClaimedHere(id, registry.plugins?.[id], projectPath, enabled, cache),
|
|
304
|
+
);
|
|
279
305
|
if (ids.length === 0) return 0;
|
|
280
306
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
307
|
+
const findings = findPluginLoadProblems(
|
|
308
|
+
installed,
|
|
309
|
+
registry.plugins,
|
|
310
|
+
projectPath,
|
|
311
|
+
enabled,
|
|
312
|
+
);
|
|
313
|
+
for (const line of pluginLoadLines(findings, ids.length)) console.log(line);
|
|
314
|
+
return findings.stale.length + findings.loadsNothing.length;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** What `magus doctor`'s "Plugin loads" section found — see {@link findPluginLoadProblems}. */
|
|
318
|
+
export interface PluginLoadFindings {
|
|
319
|
+
/** Installed here at one version, loading an older one. */
|
|
320
|
+
stale: Array<{
|
|
285
321
|
pluginId: string;
|
|
286
322
|
loaded: string;
|
|
287
323
|
recorded: string;
|
|
288
324
|
winner: string;
|
|
289
|
-
}
|
|
325
|
+
}>;
|
|
326
|
+
/**
|
|
327
|
+
* Installed here, and Claude Code loads NO version of it in this folder.
|
|
328
|
+
* Its own finding: counting it among the plugins that "load at their
|
|
329
|
+
* installed version" reported a plugin that loads nothing as healthy.
|
|
330
|
+
*/
|
|
331
|
+
loadsNothing: string[];
|
|
332
|
+
}
|
|
290
333
|
|
|
291
|
-
|
|
334
|
+
/**
|
|
335
|
+
* `enabledPlugins` as it applies to this folder: user, then project, then
|
|
336
|
+
* local, a later scope overriding an earlier one — Claude Code's precedence.
|
|
337
|
+
*/
|
|
338
|
+
export function enabledHere(
|
|
339
|
+
...scopes: ReadonlyArray<Pick<ClaudeSettings, "enabledPlugins">>
|
|
340
|
+
): Record<string, boolean> {
|
|
341
|
+
return Object.assign({}, ...scopes.map((s) => s.enabledPlugins ?? {}));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Does anything that APPLIES TO THIS FOLDER say the plugin is installed?
|
|
346
|
+
*
|
|
347
|
+
* Either a scope that applies here turns it on, or the registry holds a row
|
|
348
|
+
* the loader would consider here (`isRelevantInstallation` — the machine-wide
|
|
349
|
+
* scopes, this folder's own rows, and rows from the same repository). A row
|
|
350
|
+
* for some other project is not a claim about this folder: counting it made a
|
|
351
|
+
* stale `installedPluginVersions` entry plus an unrelated repo's install read
|
|
352
|
+
* as "installed here, loads nothing".
|
|
353
|
+
*/
|
|
354
|
+
export function isClaimedHere(
|
|
355
|
+
pluginId: string,
|
|
356
|
+
rows: readonly InstalledPluginEntry[] | undefined,
|
|
357
|
+
projectPath: string,
|
|
358
|
+
enabled: Readonly<Record<string, boolean>>,
|
|
359
|
+
cache: RepoRootCache = new Map(),
|
|
360
|
+
): boolean {
|
|
361
|
+
if (enabled[pluginId] === true) return true;
|
|
362
|
+
if (!Array.isArray(rows) || rows.length === 0) return false;
|
|
363
|
+
const root = repoRootForPath(projectPath, cache);
|
|
364
|
+
return rows.some((row) =>
|
|
365
|
+
isRelevantInstallation(row, projectPath, root, cache),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Compare what is installed here against what Claude Code will load here.
|
|
371
|
+
* Pure over its inputs, so the decision is pinned by a test rather than by a
|
|
372
|
+
* command that needs a real `~/.claude` to run.
|
|
373
|
+
*
|
|
374
|
+
* A plugin is only reported as loading nothing when something says it is
|
|
375
|
+
* INSTALLED HERE — see {@link isClaimedHere}: a scope that applies to this
|
|
376
|
+
* folder turns it on, or the registry holds a row relevant to this folder. A
|
|
377
|
+
* version in `installedPluginVersions` alone is not that — Claude Code never
|
|
378
|
+
* writes the field, so a claim with neither behind it is a leftover of
|
|
379
|
+
* magus's own, not an install that fails to load.
|
|
380
|
+
*
|
|
381
|
+
* @param installed plugin id → the version recorded as installed here
|
|
382
|
+
* @param registryPlugins the registry's rows by plugin id
|
|
383
|
+
* @param enabled `enabledPlugins` in the scopes that apply to this
|
|
384
|
+
* folder — see {@link enabledHere}
|
|
385
|
+
*/
|
|
386
|
+
export function findPluginLoadProblems(
|
|
387
|
+
installed: Readonly<Record<string, string>>,
|
|
388
|
+
registryPlugins: InstalledPluginsRegistry["plugins"] | undefined,
|
|
389
|
+
projectPath: string,
|
|
390
|
+
enabled: Readonly<Record<string, boolean>> = {},
|
|
391
|
+
): PluginLoadFindings {
|
|
392
|
+
// One cache across every plugin: the same handful of project paths repeat in
|
|
393
|
+
// hundreds of rows, and each miss walks the tree to the filesystem root.
|
|
394
|
+
const cache: RepoRootCache = new Map();
|
|
395
|
+
const findings: PluginLoadFindings = { stale: [], loadsNothing: [] };
|
|
396
|
+
|
|
397
|
+
for (const pluginId of Object.keys(installed).sort()) {
|
|
292
398
|
const recorded = installed[pluginId];
|
|
293
399
|
if (!recorded) continue;
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
cache
|
|
298
|
-
|
|
299
|
-
|
|
400
|
+
const rows = registryPlugins?.[pluginId];
|
|
401
|
+
const pick = pickLoadedEntry(rows, projectPath, cache);
|
|
402
|
+
if (!pick?.version) {
|
|
403
|
+
if (isClaimedHere(pluginId, rows, projectPath, enabled, cache))
|
|
404
|
+
findings.loadsNothing.push(pluginId);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
300
407
|
// The same predicate the classifier uses, so doctor and the plugin screen
|
|
301
408
|
// cannot disagree about which pairs count. Unorderable pairs answer null
|
|
302
409
|
// and are left alone — "cannot tell" is not a finding.
|
|
303
410
|
if (isBehind(recorded, pick.version) !== true) continue;
|
|
304
|
-
stale.push({
|
|
411
|
+
findings.stale.push({
|
|
305
412
|
pluginId,
|
|
306
413
|
loaded: pick.version,
|
|
307
414
|
recorded,
|
|
@@ -310,28 +417,42 @@ async function checkPluginLoads(projectPath: string): Promise<number> {
|
|
|
310
417
|
(pick.scope === "user" ? "user scope (no project)" : "unknown project"),
|
|
311
418
|
});
|
|
312
419
|
}
|
|
420
|
+
return findings;
|
|
421
|
+
}
|
|
313
422
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
423
|
+
/** The "Plugin loads" section's lines, for `total` plugins installed here. */
|
|
424
|
+
export function pluginLoadLines(
|
|
425
|
+
findings: PluginLoadFindings,
|
|
426
|
+
total: number,
|
|
427
|
+
): string[] {
|
|
428
|
+
const lines = ["", "Plugin loads:"];
|
|
429
|
+
if (findings.stale.length === 0 && findings.loadsNothing.length === 0) {
|
|
430
|
+
lines.push(
|
|
431
|
+
` ✓ All ${total} installed plugins load at their installed version.`,
|
|
318
432
|
);
|
|
319
|
-
return
|
|
433
|
+
return lines;
|
|
320
434
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
console.log(
|
|
435
|
+
for (const row of findings.stale) {
|
|
436
|
+
lines.push(
|
|
324
437
|
` ✗ ${row.pluginId} — loads v${row.loaded}, installed v${row.recorded}`,
|
|
438
|
+
` the entry that wins belongs to ${row.winner}`,
|
|
325
439
|
);
|
|
326
|
-
console.log(` the entry that wins belongs to ${row.winner}`);
|
|
327
440
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
441
|
+
if (findings.stale.length > 0) {
|
|
442
|
+
lines.push(
|
|
443
|
+
" Reinstalling does not fix this. Run `magus update` — it moves those",
|
|
444
|
+
" install records forward, backing up installed_plugins.json first.",
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
for (const pluginId of findings.loadsNothing) {
|
|
448
|
+
lines.push(
|
|
449
|
+
` ✗ ${pluginId} is installed, but Claude Code loads no version of it in this folder`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (findings.loadsNothing.length > 0) {
|
|
453
|
+
lines.push(" Run `magus update` to fix it.");
|
|
454
|
+
}
|
|
455
|
+
return lines;
|
|
335
456
|
}
|
|
336
457
|
|
|
337
458
|
/**
|
package/src/cli/install.ts
CHANGED
|
@@ -44,7 +44,10 @@ import {
|
|
|
44
44
|
binInstallCommand,
|
|
45
45
|
detectToolchains,
|
|
46
46
|
} from "../services/toolchain.js";
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
bringLoadForward,
|
|
49
|
+
describeLoadNotForward,
|
|
50
|
+
} from "../services/update-engine.js";
|
|
48
51
|
import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
|
|
49
52
|
import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
|
|
50
53
|
import { runCommand, runShellScript } from "../utils/run.js";
|
|
@@ -210,11 +213,7 @@ async function bringLoadsForward(
|
|
|
210
213
|
} moved forward)`,
|
|
211
214
|
);
|
|
212
215
|
} else {
|
|
213
|
-
console.warn(
|
|
214
|
-
`⚠ ${pluginId}: installed v${here}, but Claude Code still loads v${
|
|
215
|
-
forward.loaded ?? "an older version"
|
|
216
|
-
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
217
|
-
);
|
|
216
|
+
console.warn(`⚠ ${pluginId}: ${describeLoadNotForward(forward)}`);
|
|
218
217
|
}
|
|
219
218
|
}
|
|
220
219
|
}
|
package/src/cli/update-view.ts
CHANGED
|
@@ -27,11 +27,12 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { binCommandFor } from "../services/update-engine.js";
|
|
30
|
-
import
|
|
31
|
-
BinUpdateItem,
|
|
32
|
-
PluginUpdateItem,
|
|
33
|
-
SkillUpdateItem,
|
|
34
|
-
UpdatePlan,
|
|
30
|
+
import {
|
|
31
|
+
type BinUpdateItem,
|
|
32
|
+
type PluginUpdateItem,
|
|
33
|
+
type SkillUpdateItem,
|
|
34
|
+
type UpdatePlan,
|
|
35
|
+
isKeptDeprecated,
|
|
35
36
|
} from "../services/update-plan.js";
|
|
36
37
|
import { type ReportBadgeKind, reportBadge } from "../ui/theme-mode.js";
|
|
37
38
|
import { brand } from "../ui/theme.js";
|
|
@@ -84,6 +85,7 @@ type ActionKey =
|
|
|
84
85
|
| "repair"
|
|
85
86
|
| "refresh"
|
|
86
87
|
| "stale-load"
|
|
88
|
+
| "remove"
|
|
87
89
|
| "unknown";
|
|
88
90
|
|
|
89
91
|
const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
@@ -101,6 +103,10 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
|
101
103
|
// a mild version of `repair`, and this is not that. Six characters, one under
|
|
102
104
|
// UNKNOWN, so CHIP_WIDTH is unchanged and no other section pays for it.
|
|
103
105
|
"stale-load": { glyph: "✗", label: "BROKEN", color: brand.danger },
|
|
106
|
+
// No longer published, and removed by the apply. ✗ in danger: the plugin is
|
|
107
|
+
// dead weight Claude Code refuses to load, and the row is work, not a shrug.
|
|
108
|
+
// The plugin table shows the group's DEPRECATED chip, not this label.
|
|
109
|
+
remove: { glyph: "✗", label: "REMOVE", color: brand.danger },
|
|
104
110
|
// Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
|
|
105
111
|
// as "broken". The summary line and the TUI already use warning for it.
|
|
106
112
|
unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
|
|
@@ -373,10 +379,15 @@ function pluginDetail(item: PluginUpdateItem): string {
|
|
|
373
379
|
// "loads v0.3.0, installed v0.5.0" and the two columns beside it already do.
|
|
374
380
|
const note =
|
|
375
381
|
item.note &&
|
|
376
|
-
item.reason !== "
|
|
382
|
+
item.reason !== "deprecated" &&
|
|
377
383
|
item.reason !== "older-version-loads"
|
|
378
384
|
? ` ${dim(`(${item.note})`)}`
|
|
379
385
|
: "";
|
|
386
|
+
// Kept on purpose — moved, or still required. Under Deprecated, and the
|
|
387
|
+
// word says nothing will happen to it; the note says why.
|
|
388
|
+
if (isKeptDeprecated(item)) {
|
|
389
|
+
return `${item.installed ?? "—"} ${dim(`kept — ${item.note ?? "no longer published"}`)}`;
|
|
390
|
+
}
|
|
380
391
|
switch (item.action) {
|
|
381
392
|
case "install":
|
|
382
393
|
return `${item.target ?? "latest"}${note}`;
|
|
@@ -391,6 +402,7 @@ function pluginDetail(item: PluginUpdateItem): string {
|
|
|
391
402
|
)} ${fg(groupStyle("Update").text, item.target ?? "?")}${note}`;
|
|
392
403
|
case "repair":
|
|
393
404
|
case "unknown":
|
|
405
|
+
case "remove":
|
|
394
406
|
return `${item.installed ?? "—"}${note}`;
|
|
395
407
|
case "stale-load":
|
|
396
408
|
// Both versions, in the order the reader needs them: what runs, then
|
|
@@ -459,7 +471,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
459
471
|
* note. Five headings, and no more:
|
|
460
472
|
*
|
|
461
473
|
* Update something an apply would move forward
|
|
462
|
-
* Deprecated no
|
|
474
|
+
* Deprecated no longer published — an apply removes it
|
|
463
475
|
* Fix required declared or installed, with its files missing
|
|
464
476
|
* Unverified the check itself did not complete
|
|
465
477
|
* Current settled — the version was compared, or there was none to compare
|
|
@@ -485,9 +497,11 @@ function pluginGroup(item: PluginUpdateItem): string {
|
|
|
485
497
|
// state deceptive, and a row that reads as up to date under a CURRENT
|
|
486
498
|
// heading is the defect this state was added to end.
|
|
487
499
|
if (item.action === "stale-load") return "Broken";
|
|
488
|
-
if (item.action === "
|
|
489
|
-
|
|
490
|
-
|
|
500
|
+
if (item.action === "remove") return "Deprecated";
|
|
501
|
+
// Moved or still required: no longer published here, and KEPT. That is a
|
|
502
|
+
// determined fact, not a check that failed, so never "Unverified".
|
|
503
|
+
if (isKeptDeprecated(item)) return "Deprecated";
|
|
504
|
+
if (item.action === "unknown") return "Unverified";
|
|
491
505
|
return "Current";
|
|
492
506
|
}
|
|
493
507
|
|
|
@@ -590,9 +604,12 @@ function pluginRow(
|
|
|
590
604
|
: badge(groupBadge(group), style.fill ?? brand.muted, style.ink),
|
|
591
605
|
chipWidth,
|
|
592
606
|
);
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
607
|
+
// A kept row is not work: the ✗ of a removal would say it is, and the "?"
|
|
608
|
+
// of an unknown that nobody knows. It gets the settled row's muted glyph.
|
|
609
|
+
const mark = isKeptDeprecated(item)
|
|
610
|
+
? fg(brand.muted, ACTIONS.current.glyph)
|
|
611
|
+
: fg(style.text, ACTIONS[item.action].glyph);
|
|
612
|
+
return `${PAD}${mark} ${style.chrome ? dim(name) : name} ${chip}${pluginDetail(item)}`;
|
|
596
613
|
}
|
|
597
614
|
|
|
598
615
|
/** The Plugins section: grouped by state, `Current` last. */
|
|
@@ -681,7 +698,7 @@ export function planReport(plan: UpdatePlan): string[] {
|
|
|
681
698
|
// EVERY planned plugin gets a row. There is no filter here and there must
|
|
682
699
|
// not be one.
|
|
683
700
|
//
|
|
684
|
-
// A previous revision dropped
|
|
701
|
+
// A previous revision dropped unlisted, not-installed rows
|
|
685
702
|
// — a pin naming a plugin no marketplace offers — on the grounds that a
|
|
686
703
|
// notice named them instead. That notice did not exist: the function the
|
|
687
704
|
// comment cited was never written. The row appeared in no table and no
|
|
@@ -778,10 +795,11 @@ export function summaryLine(
|
|
|
778
795
|
failed: number,
|
|
779
796
|
ms: number,
|
|
780
797
|
unchanged = 0,
|
|
798
|
+
kept = 0,
|
|
781
799
|
): string[] {
|
|
782
800
|
const bar = stackedBar(BAR * 2, [
|
|
783
801
|
{ value: changed, color: brand.success },
|
|
784
|
-
{ value: current, color: brand.muted },
|
|
802
|
+
{ value: current + kept, color: brand.muted },
|
|
785
803
|
{ value: unchanged + unknown, color: brand.warning },
|
|
786
804
|
{ value: failed, color: brand.danger },
|
|
787
805
|
]);
|
|
@@ -789,6 +807,9 @@ export function summaryLine(
|
|
|
789
807
|
changed > 0 ? fg(brand.success, `${changed} changed`) : "",
|
|
790
808
|
unchanged > 0 ? fg(brand.warning, `${unchanged} unchanged`) : "",
|
|
791
809
|
current > 0 ? dim(`${current} current`) : "",
|
|
810
|
+
// Deprecated but kept on purpose (moved, still required): settled, so
|
|
811
|
+
// grey beside `current`, and never folded into "unverified".
|
|
812
|
+
kept > 0 ? dim(`${kept} kept`) : "",
|
|
792
813
|
unknown > 0 ? fg(brand.warning, `${unknown} unverified`) : "",
|
|
793
814
|
failed > 0 ? fg(brand.danger, `${failed} failed`) : "",
|
|
794
815
|
].filter(Boolean);
|
|
@@ -860,3 +881,25 @@ export function nothingToApplyNotice(unknownPlugins: number): string {
|
|
|
860
881
|
`Nothing to apply. ${unknownPlugins} plugin(s) could not be checked. They are NOT reported as up to date. Reasons are shown above.`,
|
|
861
882
|
);
|
|
862
883
|
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The words `--check` fails with, chosen by what is pending. "Updates
|
|
887
|
+
* available" over a plan whose only work is removing plugins that are no
|
|
888
|
+
* longer published claims an update nobody can find in the table above.
|
|
889
|
+
*/
|
|
890
|
+
export function pendingHeadline(plan: UpdatePlan): string {
|
|
891
|
+
const pendingPlugins = plan.plugins.filter(
|
|
892
|
+
(p) =>
|
|
893
|
+
p.action === "install" ||
|
|
894
|
+
p.action === "update" ||
|
|
895
|
+
p.action === "repair" ||
|
|
896
|
+
p.action === "stale-load" ||
|
|
897
|
+
p.action === "remove",
|
|
898
|
+
);
|
|
899
|
+
const onlyRemovals =
|
|
900
|
+
pendingPlugins.length > 0 &&
|
|
901
|
+
pendingPlugins.every((p) => p.action === "remove") &&
|
|
902
|
+
!plan.bins.some((b) => b.action === "install" || b.action === "upgrade") &&
|
|
903
|
+
!plan.skills.some((s) => s.action === "install");
|
|
904
|
+
return onlyRemovals ? "Changes pending" : "Updates available";
|
|
905
|
+
}
|
package/src/cli/update.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
type PluginInfo,
|
|
48
48
|
clearMarketplaceCache,
|
|
49
49
|
getAvailablePlugins,
|
|
50
|
+
isInstalledAnywhere,
|
|
50
51
|
} from "../services/plugin-manager.js";
|
|
51
52
|
import {
|
|
52
53
|
recordRefetchedSkills,
|
|
@@ -61,7 +62,7 @@ import {
|
|
|
61
62
|
switchHint,
|
|
62
63
|
withCliBookkeeping,
|
|
63
64
|
} from "../services/profile-state.js";
|
|
64
|
-
import { resolveProfile } from "../services/resolver.js";
|
|
65
|
+
import { requirersHere, resolveProfile } from "../services/resolver.js";
|
|
65
66
|
import { getInstalledSkillNames } from "../services/skills-manager.js";
|
|
66
67
|
import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
|
|
67
68
|
// The apply half lives in services/ so the TUI runs the SAME code — see the
|
|
@@ -69,6 +70,7 @@ import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
|
|
|
69
70
|
import {
|
|
70
71
|
type ApplyReporter,
|
|
71
72
|
applyPlan,
|
|
73
|
+
describeRemoved,
|
|
72
74
|
planWorkItems,
|
|
73
75
|
} from "../services/update-engine.js";
|
|
74
76
|
import {
|
|
@@ -95,6 +97,7 @@ import {
|
|
|
95
97
|
note as noteLine,
|
|
96
98
|
nothingToApplyNotice,
|
|
97
99
|
ok as okLine,
|
|
100
|
+
pendingHeadline,
|
|
98
101
|
planReport,
|
|
99
102
|
stepFrame,
|
|
100
103
|
stepRecord,
|
|
@@ -441,9 +444,17 @@ export async function runUpdateCommand(
|
|
|
441
444
|
targetId,
|
|
442
445
|
closure.skills,
|
|
443
446
|
);
|
|
447
|
+
// Every installed plugin's dependencies count, not only the profile's: a
|
|
448
|
+
// plugin still required by one that stays is never planned for removal, and
|
|
449
|
+
// retired plugins that only require each other are removed together.
|
|
450
|
+
const requiredBy = await requirersHere(
|
|
451
|
+
closure.dependencies?.requiredBy,
|
|
452
|
+
[...catalog.values()].filter(isInstalledAnywhere).map((p) => p.id),
|
|
453
|
+
projectPath,
|
|
454
|
+
);
|
|
444
455
|
const plan: UpdatePlan = {
|
|
445
456
|
profileId: targetId,
|
|
446
|
-
plugins: planPluginUpdates(closure.plugins, catalog),
|
|
457
|
+
plugins: planPluginUpdates(closure.plugins, catalog, requiredBy),
|
|
447
458
|
bins: planBinUpdates(closure.bins, toolFacts),
|
|
448
459
|
skills: planSkillUpdates(skillRefs, installedSkills),
|
|
449
460
|
binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
|
|
@@ -489,7 +500,9 @@ export async function runUpdateCommand(
|
|
|
489
500
|
return 0;
|
|
490
501
|
}
|
|
491
502
|
console.error(
|
|
492
|
-
failLine(
|
|
503
|
+
failLine(
|
|
504
|
+
`${pendingHeadline(plan)}. Run ${bold("magus update")} to apply.`,
|
|
505
|
+
),
|
|
493
506
|
);
|
|
494
507
|
return 1;
|
|
495
508
|
}
|
|
@@ -506,6 +519,8 @@ export async function runUpdateCommand(
|
|
|
506
519
|
counts.unknown,
|
|
507
520
|
0,
|
|
508
521
|
Date.now() - startedAt,
|
|
522
|
+
0,
|
|
523
|
+
counts.kept,
|
|
509
524
|
))
|
|
510
525
|
console.log(line);
|
|
511
526
|
// `planHasWork` counts an `unknown` plugin as work, because from the
|
|
@@ -539,8 +554,10 @@ export async function runUpdateCommand(
|
|
|
539
554
|
|
|
540
555
|
// No confirmation. `update` moves the machine toward what the profile
|
|
541
556
|
// declares and can only go FORWARD: `claude plugin install` takes no version
|
|
542
|
-
// and installs the newest published one, so there is no downgrade
|
|
543
|
-
//
|
|
557
|
+
// and installs the newest published one, so there is no downgrade for a
|
|
558
|
+
// prompt to protect against. The one delete it makes is a DEPRECATED plugin:
|
|
559
|
+
// its marketplace no longer publishes it and Claude Code refuses to load it
|
|
560
|
+
// (measured, 2.1.284), so removing it loses nothing. The plan is printed above, so the
|
|
544
561
|
// run is still legible after the fact — and `--dry-run` shows it beforehand.
|
|
545
562
|
//
|
|
546
563
|
// Note this also runs a profile's CLI-tool installers unattended (`brew
|
|
@@ -637,9 +654,14 @@ export async function runUpdateCommand(
|
|
|
637
654
|
failed.length,
|
|
638
655
|
Date.now() - startedAt,
|
|
639
656
|
outcome.unchanged.length,
|
|
657
|
+
counts.kept,
|
|
640
658
|
))
|
|
641
659
|
console.log(line);
|
|
642
660
|
|
|
661
|
+
// Only the CLI says it: on the Plugins screen the removed rows are simply gone.
|
|
662
|
+
const removed = describeRemoved(outcome.plugins.removed);
|
|
663
|
+
if (removed) console.log(okLine(removed));
|
|
664
|
+
|
|
643
665
|
// Exited 0, read back unmoved. Reported, never fatal — the same precedent
|
|
644
666
|
// as the pin mismatches below: this is a standing property of the machine
|
|
645
667
|
// (a stale tap, a shadowing PATH entry), not something this run did wrong,
|
|
@@ -35,12 +35,14 @@ import type { MarketplacePlugin } from "./marketplace-catalog.js";
|
|
|
35
35
|
/**
|
|
36
36
|
* How long a cached catalog is served before we ask again.
|
|
37
37
|
*
|
|
38
|
-
* This is the ONLY refresh rhythm magus has, and it
|
|
39
|
-
* `magus update`
|
|
40
|
-
* `update`
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
38
|
+
* This is the ONLY refresh rhythm magus has, and it applies to both surfaces:
|
|
39
|
+
* `magus update` and the TUI read a catalog written here within the hour
|
|
40
|
+
* before they go to the network. `update` pulls the marketplace clones and
|
|
41
|
+
* drops its in-process memo before planning, but it does NOT clear this file,
|
|
42
|
+
* so a release published inside the TTL is not seen by `update` either. Only
|
|
43
|
+
* an explicit refresh (`r` in the TUI) clears it. Anything that reports "no
|
|
44
|
+
* update" — or decides a plugin is no longer published — off a cached catalog
|
|
45
|
+
* is working from an answer up to an hour old, by design.
|
|
44
46
|
*/
|
|
45
47
|
export const CATALOG_TTL_MS = 60 * 60 * 1000;
|
|
46
48
|
|