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
|
@@ -69,11 +69,7 @@ import {
|
|
|
69
69
|
findEnclosingManifest,
|
|
70
70
|
readManifest,
|
|
71
71
|
} from "./manifest.js";
|
|
72
|
-
import {
|
|
73
|
-
type ActivationResult,
|
|
74
|
-
type ProfileChoice,
|
|
75
|
-
activationNotes,
|
|
76
|
-
} from "./profile-activate.js";
|
|
72
|
+
import type { ActivationResult, ProfileChoice } from "./profile-activate.js";
|
|
77
73
|
import { freeProfileId } from "./profile-adopt.js";
|
|
78
74
|
import {
|
|
79
75
|
activeProfile,
|
|
@@ -311,36 +307,29 @@ export async function oldFormatNeed(
|
|
|
311
307
|
};
|
|
312
308
|
}
|
|
313
309
|
|
|
314
|
-
/**
|
|
310
|
+
/** The TUI's startup: the question (if any), and what the gate refused. */
|
|
315
311
|
export interface ProfileStartup {
|
|
316
312
|
need: ProfileStartupNeed;
|
|
317
313
|
/**
|
|
318
|
-
* One line per thing the gate
|
|
319
|
-
*
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* One line per thing the gate REFUSED — the lines the CLI stops on. Kept
|
|
324
|
-
* apart from `notices` because a refusal can leave detection reading `ok`:
|
|
325
|
-
* an old layout whose settings file does not parse is not converted, yet
|
|
326
|
-
* still names its profile, so only this says the update did not happen.
|
|
314
|
+
* One line per thing the gate REFUSED — the lines the CLI stops on. A
|
|
315
|
+
* refusal can leave detection reading `ok`: an old layout whose settings
|
|
316
|
+
* file does not parse is not converted, yet still names its profile, so only
|
|
317
|
+
* this says the update did not happen.
|
|
327
318
|
*/
|
|
328
319
|
problems: string[];
|
|
329
320
|
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* load" in a line nothing marks as a warning. The TUI words them itself, in
|
|
334
|
-
* user terms and with a tone (`activationWarnings` in the startup hook).
|
|
321
|
+
* Every activation the gate ran — a re-render after profiles.json changed,
|
|
322
|
+
* an older format converted — so the TUI can say what one of them could
|
|
323
|
+
* not do. Absent is none.
|
|
335
324
|
*/
|
|
336
|
-
activations
|
|
325
|
+
activations?: ActivationResult[];
|
|
337
326
|
}
|
|
338
327
|
|
|
339
328
|
/**
|
|
340
329
|
* The TUI's startup: the same gate every CLI command runs, then detection.
|
|
341
330
|
*
|
|
342
|
-
* A failure in the gate never blocks the TUI — its message
|
|
343
|
-
* and detection still runs, so the user is asked whatever is left
|
|
331
|
+
* A failure in the gate never blocks the TUI — its message is kept in
|
|
332
|
+
* `problems` and detection still runs, so the user is asked whatever is left.
|
|
344
333
|
*/
|
|
345
334
|
export async function prepareProfileStartup(
|
|
346
335
|
projectPath: string,
|
|
@@ -354,21 +343,13 @@ export async function prepareProfileStartup(
|
|
|
354
343
|
if (!opts.convert) {
|
|
355
344
|
const format = await oldFormatNeed(projectPath);
|
|
356
345
|
if (format !== null) {
|
|
357
|
-
return { need: format,
|
|
346
|
+
return { need: format, problems: [] };
|
|
358
347
|
}
|
|
359
348
|
}
|
|
360
|
-
const notices: string[] = [];
|
|
361
349
|
const problems: string[] = [];
|
|
362
350
|
const activations: ActivationResult[] = [];
|
|
363
351
|
try {
|
|
364
352
|
const prep = await prepareProfileState(projectPath);
|
|
365
|
-
// The gate's lines, less the ones its activations contributed: those
|
|
366
|
-
// reach the TUI as `activations`, and passing both would say the same
|
|
367
|
-
// thing twice — once in the CLI's words, as if it were a success.
|
|
368
|
-
const fromActivations = new Set(
|
|
369
|
-
prep.activations.flatMap((a) => activationNotes(a, { tracked: false })),
|
|
370
|
-
);
|
|
371
|
-
notices.push(...prep.lines.filter((line) => !fromActivations.has(line)));
|
|
372
353
|
problems.push(...prep.problems);
|
|
373
354
|
activations.push(...prep.activations);
|
|
374
355
|
} catch (error) {
|
|
@@ -378,19 +359,18 @@ export async function prepareProfileStartup(
|
|
|
378
359
|
}
|
|
379
360
|
return {
|
|
380
361
|
need: await detectProfileStartupNeed(projectPath),
|
|
381
|
-
notices,
|
|
382
362
|
problems,
|
|
383
|
-
activations,
|
|
363
|
+
...(activations.length > 0 ? { activations } : {}),
|
|
384
364
|
};
|
|
385
365
|
}
|
|
386
366
|
|
|
387
367
|
/**
|
|
388
|
-
* What the TUI
|
|
389
|
-
*
|
|
368
|
+
* What the TUI does once "Update it now" has run: ask another question, carry
|
|
369
|
+
* on, or show an error modal.
|
|
390
370
|
*/
|
|
391
371
|
export type FormatUpdateOutcome =
|
|
392
372
|
| { kind: "ask"; need: ProfileStartupNeed }
|
|
393
|
-
| { kind: "updated"
|
|
373
|
+
| { kind: "updated" }
|
|
394
374
|
| { kind: "failed"; title: string; message: string };
|
|
395
375
|
|
|
396
376
|
/**
|
|
@@ -410,12 +390,7 @@ export function formatUpdateOutcome(
|
|
|
410
390
|
};
|
|
411
391
|
}
|
|
412
392
|
if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
|
|
413
|
-
|
|
414
|
-
// (edits carried over, the old format's backup), and the result the user
|
|
415
|
-
// cares about is only that the update is done. What the conversion's
|
|
416
|
-
// activation warns about is NOT dropped: the caller words it from
|
|
417
|
-
// `result.activations`, as it does for any activation.
|
|
418
|
-
return { kind: "updated", notice: "Profile format updated" };
|
|
393
|
+
return { kind: "updated" };
|
|
419
394
|
}
|
|
420
395
|
|
|
421
396
|
// ─── What to offer, as data both surfaces render ─────────────────────────────
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import type { ProfileManifest } from "../types/index.js";
|
|
30
|
-
import {
|
|
29
|
+
import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
|
|
30
|
+
import { selectProfileId } from "./active-closure.js";
|
|
31
|
+
import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
|
|
31
32
|
import {
|
|
32
33
|
type ReconcileResult,
|
|
33
34
|
absorbOlderLayout,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
type ActiveMarker,
|
|
48
49
|
LIVE_FILE_KEYS,
|
|
49
50
|
MACHINE_LOCAL_SETTINGS_KEYS,
|
|
51
|
+
activeProfile,
|
|
50
52
|
folderHash,
|
|
51
53
|
hasCurrentMarker,
|
|
52
54
|
hasOlderLayout,
|
|
@@ -68,6 +70,7 @@ import {
|
|
|
68
70
|
inspectManagedSkills,
|
|
69
71
|
skillFolder,
|
|
70
72
|
} from "./profile-skills.js";
|
|
73
|
+
import { resolveExtends } from "./resolver.js";
|
|
71
74
|
import type { SettingsParseError } from "./settings-file.js";
|
|
72
75
|
|
|
73
76
|
export type ActiveProfileCheck =
|
|
@@ -652,3 +655,70 @@ export async function withCliBookkeeping<T>(
|
|
|
652
655
|
for (const line of reconciled.lines) opts.report?.(line);
|
|
653
656
|
}
|
|
654
657
|
}
|
|
658
|
+
|
|
659
|
+
/** Whether a profile entry, `extends` included, turns the plugin on. */
|
|
660
|
+
function profileLists(
|
|
661
|
+
entry: ProfileManifestEntry | undefined,
|
|
662
|
+
pluginId: string,
|
|
663
|
+
): boolean {
|
|
664
|
+
if (!entry) return false;
|
|
665
|
+
const value = resolveExtends(entry).plugins?.[pluginId];
|
|
666
|
+
return value !== undefined && value !== false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Stop a profile listing `pluginId`, so its next activation does not turn the
|
|
671
|
+
* plugin back on. Answers whether the profile no longer lists it — read back
|
|
672
|
+
* from profiles.json after the write, never assumed.
|
|
673
|
+
*
|
|
674
|
+
* For a deprecated plugin `magus update` removes. The uninstall that runs first
|
|
675
|
+
* deletes the plugin from this project's settings (measured, Claude Code
|
|
676
|
+
* 2.1.284), and `reconcileActiveProfile` would absorb that into the ACTIVE
|
|
677
|
+
* profile at the next entry point — but only there, and only for the active
|
|
678
|
+
* one. The Plugins screen's `a` runs no reconcile afterwards, so the screen kept
|
|
679
|
+
* the plugin declared and planned the same removal again; `magus update <name>`
|
|
680
|
+
* plans a profile that is not active, whose live files nothing absorbs from;
|
|
681
|
+
* and a profile declaring a plugin that is installed nowhere has no settings
|
|
682
|
+
* change to absorb at all. So the entry is edited here, directly, through
|
|
683
|
+
* {@link updateProfileEntry} — the editor `magus models use` writes through.
|
|
684
|
+
* The reconcile that follows (`withCliBookkeeping`, or the next entry point)
|
|
685
|
+
* finds profiles.json and the live files agreeing, and regenerates from it.
|
|
686
|
+
*
|
|
687
|
+
* `profile` undefined means the active one, chosen as the Plugins screen and
|
|
688
|
+
* `magus update` choose it; null means no profile, which lists nothing.
|
|
689
|
+
*
|
|
690
|
+
* Deletes the entry's own pin. When `extends` supplies the plugin, the pin
|
|
691
|
+
* becomes `false`, which is how an entry turns an inherited plugin off.
|
|
692
|
+
* Throws `SettingsParseError` when profiles.json exists and does not parse.
|
|
693
|
+
*/
|
|
694
|
+
export async function removePluginFromProfile(
|
|
695
|
+
projectPath: string,
|
|
696
|
+
pluginId: string,
|
|
697
|
+
profile?: string | null,
|
|
698
|
+
): Promise<boolean> {
|
|
699
|
+
const manifest = await readManifest(projectPath);
|
|
700
|
+
const id =
|
|
701
|
+
profile === undefined
|
|
702
|
+
? selectProfileId(
|
|
703
|
+
Object.keys(manifest.profiles),
|
|
704
|
+
undefined,
|
|
705
|
+
await activeProfile(projectPath),
|
|
706
|
+
)
|
|
707
|
+
: profile;
|
|
708
|
+
if (!id || !profileLists(manifest.profiles[id], pluginId)) return true;
|
|
709
|
+
|
|
710
|
+
await updateProfileEntry(projectPath, id, (entry) => {
|
|
711
|
+
const plugins = { ...(entry.plugins ?? {}) };
|
|
712
|
+
delete plugins[pluginId];
|
|
713
|
+
if (profileLists({ ...entry, plugins }, pluginId))
|
|
714
|
+
plugins[pluginId] = false;
|
|
715
|
+
if (Object.keys(plugins).length > 0) entry.plugins = plugins;
|
|
716
|
+
// biome-ignore lint/performance/noDelete: absent, not undefined, in profiles.json
|
|
717
|
+
else delete entry.plugins;
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
return !profileLists(
|
|
721
|
+
(await readManifest(projectPath)).profiles[id],
|
|
722
|
+
pluginId,
|
|
723
|
+
);
|
|
724
|
+
}
|
package/src/services/resolver.ts
CHANGED
|
@@ -60,6 +60,7 @@ export async function resolveDependencies(
|
|
|
60
60
|
read: PluginDependencyReader,
|
|
61
61
|
): Promise<DependencyReport> {
|
|
62
62
|
const derived: string[] = [];
|
|
63
|
+
const requiredBy: Record<string, string> = {};
|
|
63
64
|
const unreadable: string[] = [];
|
|
64
65
|
const blocked: DependencyReport["blocked"] = [];
|
|
65
66
|
const queue = [...enabled];
|
|
@@ -75,13 +76,105 @@ export async function resolveDependencies(
|
|
|
75
76
|
blocked.push({ plugin: id, dependency });
|
|
76
77
|
continue;
|
|
77
78
|
}
|
|
79
|
+
// Every edge, including to a plugin the profile names itself: "still
|
|
80
|
+
// required" is a fact about the plugin that needs it, not about how
|
|
81
|
+
// the profile came to enable it. Recording only derived ones let a
|
|
82
|
+
// no-longer-published dependency the profile ALSO listed be planned
|
|
83
|
+
// for removal from under the plugin that needs it.
|
|
84
|
+
if (dependency !== id && !Object.hasOwn(requiredBy, dependency)) {
|
|
85
|
+
requiredBy[dependency] = id;
|
|
86
|
+
}
|
|
78
87
|
if (seen.has(dependency)) continue;
|
|
79
88
|
seen.add(dependency);
|
|
80
89
|
derived.push(dependency);
|
|
81
90
|
queue.push(dependency);
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
derived: derived.sort(),
|
|
95
|
+
requiredBy,
|
|
96
|
+
unreadable: unreadable.sort(),
|
|
97
|
+
blocked,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Which plugin requires each plugin, for the planner's STILL-REQUIRED rule:
|
|
103
|
+
* the profile's edges (`DependencyReport.requiredBy`) and, beside them, the
|
|
104
|
+
* edges every INSTALLED plugin's own plugin.json declares.
|
|
105
|
+
*
|
|
106
|
+
* The profile alone is not enough. A plugin installed at USER scope, or in a
|
|
107
|
+
* project with no profile at all, is planned too — and without its edges a
|
|
108
|
+
* dependency it needs that stopped being published was planned for removal
|
|
109
|
+
* from under it. One plugin.json read per installed plugin, the same read the
|
|
110
|
+
* profile's resolve makes. The profile's answer wins where both name one.
|
|
111
|
+
*
|
|
112
|
+
* ONE function for `magus update` and the Plugins screen, so the two plan the
|
|
113
|
+
* same removals.
|
|
114
|
+
*/
|
|
115
|
+
export async function requiredByHere(
|
|
116
|
+
profileRequiredBy: Readonly<Record<string, string>> | undefined,
|
|
117
|
+
installedIds: readonly string[],
|
|
118
|
+
projectPath?: string,
|
|
119
|
+
read: PluginDependencyReader = readPluginDependencies,
|
|
120
|
+
): Promise<Record<string, string>> {
|
|
121
|
+
const all = await requirersHere(
|
|
122
|
+
profileRequiredBy,
|
|
123
|
+
installedIds,
|
|
124
|
+
projectPath,
|
|
125
|
+
read,
|
|
126
|
+
);
|
|
127
|
+
const first: Record<string, string> = {};
|
|
128
|
+
for (const [dependency, requirers] of Object.entries(all)) {
|
|
129
|
+
const [requirer] = requirers;
|
|
130
|
+
if (requirer) first[dependency] = requirer;
|
|
131
|
+
}
|
|
132
|
+
return first;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* {@link requiredByHere} with EVERY requirer of each plugin, not only the
|
|
137
|
+
* first — what the planner needs to remove deprecated plugins that require
|
|
138
|
+
* one another together, while keeping one that anything staying still needs.
|
|
139
|
+
* The profile's requirer comes first; the installed plugins' follow, sorted.
|
|
140
|
+
*
|
|
141
|
+
* This is what `magus update` and the Plugins screen plan with.
|
|
142
|
+
*/
|
|
143
|
+
export async function requirersHere(
|
|
144
|
+
profileRequiredBy: Readonly<Record<string, string>> | undefined,
|
|
145
|
+
installedIds: readonly string[],
|
|
146
|
+
projectPath?: string,
|
|
147
|
+
read: PluginDependencyReader = readPluginDependencies,
|
|
148
|
+
): Promise<Record<string, string[]>> {
|
|
149
|
+
const declared = await Promise.all(
|
|
150
|
+
[...installedIds].sort().map(async (id) => {
|
|
151
|
+
try {
|
|
152
|
+
return [id, await read(id, projectPath)] as const;
|
|
153
|
+
} catch {
|
|
154
|
+
return [id, null] as const;
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
const edges: Record<string, string[]> = {};
|
|
159
|
+
const add = (dependency: string, requirer: string, front = false) => {
|
|
160
|
+
if (dependency === requirer) return;
|
|
161
|
+
const list = (
|
|
162
|
+
Object.hasOwn(edges, dependency) ? (edges[dependency] as string[]) : []
|
|
163
|
+
).filter((r) => !front || r !== requirer);
|
|
164
|
+
if (list.includes(requirer)) return;
|
|
165
|
+
if (front) list.unshift(requirer);
|
|
166
|
+
else list.push(requirer);
|
|
167
|
+
edges[dependency] = list;
|
|
168
|
+
};
|
|
169
|
+
for (const [id, dependencies] of declared) {
|
|
170
|
+
for (const dependency of dependencies ?? []) add(dependency, id);
|
|
171
|
+
}
|
|
172
|
+
for (const [dependency, requirer] of Object.entries(
|
|
173
|
+
profileRequiredBy ?? {},
|
|
174
|
+
)) {
|
|
175
|
+
add(dependency, requirer, true);
|
|
176
|
+
}
|
|
177
|
+
return edges;
|
|
85
178
|
}
|
|
86
179
|
|
|
87
180
|
function hasDependencies(report: DependencyReport): boolean {
|
|
@@ -444,6 +537,7 @@ function mergeDependencyReports(
|
|
|
444
537
|
);
|
|
445
538
|
return {
|
|
446
539
|
derived: [...new Set([...a.derived, ...b.derived])].sort(),
|
|
540
|
+
requiredBy: { ...b.requiredBy, ...a.requiredBy },
|
|
447
541
|
unreadable: [...new Set([...a.unreadable, ...b.unreadable])].sort(),
|
|
448
542
|
blocked: [...blocked.values()],
|
|
449
543
|
};
|