magus-cli 7.5.3 → 7.6.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-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files missing at a scope is repaired AT THAT SCOPE.
|
|
3
|
+
*
|
|
4
|
+
* The plan gave reason `files-missing` no scopes, and the engine read every
|
|
5
|
+
* `install` as "project scope". So a plugin installed at user scope with its
|
|
6
|
+
* files missing ran `claude plugin install <id> --scope project`, which turns it
|
|
7
|
+
* on in the project's settings.json — and for a plugin the active profile does
|
|
8
|
+
* not declare, the reconcile after the run took that record for the user's own
|
|
9
|
+
* edit and wrote it into the team's committed profiles.json. The TUI's `a`
|
|
10
|
+
* reached the same path.
|
|
11
|
+
*
|
|
12
|
+
* Every plan here comes from the real classifier (`planPluginUpdates`) and every
|
|
13
|
+
* apply from the real engine (`applyPlugins`); only the Claude CLI is faked.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
17
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import fs from "fs-extra";
|
|
21
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
22
|
+
import { writeManifest } from "../services/manifest.js";
|
|
23
|
+
import type { PluginInfo, ScopeStatus } from "../services/plugin-manager.js";
|
|
24
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
25
|
+
import { readActiveMarker } from "../services/profile-live.js";
|
|
26
|
+
import { withCliBookkeeping } from "../services/profile-state.js";
|
|
27
|
+
import {
|
|
28
|
+
type PluginApplyDeps,
|
|
29
|
+
applyPlugins,
|
|
30
|
+
pluginNeedsWork,
|
|
31
|
+
} from "../services/update-engine.js";
|
|
32
|
+
import {
|
|
33
|
+
type PluginUpdateItem,
|
|
34
|
+
planPluginUpdates,
|
|
35
|
+
} from "../services/update-plan.js";
|
|
36
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
37
|
+
|
|
38
|
+
const STRAY = "stray@magus";
|
|
39
|
+
const DEV = "dev@magus";
|
|
40
|
+
|
|
41
|
+
function info(
|
|
42
|
+
id: string,
|
|
43
|
+
scopes: {
|
|
44
|
+
user?: ScopeStatus;
|
|
45
|
+
project?: ScopeStatus;
|
|
46
|
+
local?: ScopeStatus;
|
|
47
|
+
},
|
|
48
|
+
): PluginInfo {
|
|
49
|
+
const [name, marketplace] = id.split("@") as [string, string];
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
name,
|
|
53
|
+
version: "1.0.0",
|
|
54
|
+
description: "",
|
|
55
|
+
marketplace,
|
|
56
|
+
marketplaceDisplay: "Magus",
|
|
57
|
+
userScope: scopes.user,
|
|
58
|
+
projectScope: scopes.project,
|
|
59
|
+
localScope: scopes.local,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The plan item for `id`, from the real classifier. */
|
|
64
|
+
function planFor(
|
|
65
|
+
id: string,
|
|
66
|
+
plugins: PluginInfo[],
|
|
67
|
+
pins: Record<string, string> = {},
|
|
68
|
+
): PluginUpdateItem {
|
|
69
|
+
const item = planPluginUpdates(pins, plugins).find((i) => i.pluginId === id);
|
|
70
|
+
if (!item) throw new Error(`no plan item for ${id}`);
|
|
71
|
+
return item;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A fake Claude CLI that records each command's scope. `onInstall` is what the
|
|
76
|
+
* real one writes as a side effect — see the reconcile case.
|
|
77
|
+
*/
|
|
78
|
+
function fakeCli(
|
|
79
|
+
onInstall: (id: string, scope: PluginScope) => Promise<void> = async () => {},
|
|
80
|
+
) {
|
|
81
|
+
const installs: Array<{ id: string; scope: PluginScope }> = [];
|
|
82
|
+
const updates: Array<{ id: string; scope: PluginScope }> = [];
|
|
83
|
+
const deps: PluginApplyDeps = {
|
|
84
|
+
install: async (id, scope) => {
|
|
85
|
+
installs.push({ id, scope });
|
|
86
|
+
await onInstall(id, scope);
|
|
87
|
+
},
|
|
88
|
+
update: async (id, scope) => {
|
|
89
|
+
updates.push({ id, scope });
|
|
90
|
+
},
|
|
91
|
+
repair: async () => {},
|
|
92
|
+
readInstalled: async () => "1.0.0",
|
|
93
|
+
saveInstalled: async () => {},
|
|
94
|
+
repairStaleLoad: async () => {
|
|
95
|
+
throw new Error("no stale-load repair expected");
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return { deps, installs, updates };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("the plan names the scopes whose files are missing", () => {
|
|
102
|
+
test("files missing at user scope plans user scope", () => {
|
|
103
|
+
const item = planFor(STRAY, [info(STRAY, { user: { installed: true } })]);
|
|
104
|
+
expect(item.action).toBe("install");
|
|
105
|
+
expect(item.reason).toBe("files-missing");
|
|
106
|
+
expect(item.scopes).toEqual(["user"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("files missing at local scope plans local scope", () => {
|
|
110
|
+
const item = planFor(STRAY, [info(STRAY, { local: { installed: true } })]);
|
|
111
|
+
expect(item.reason).toBe("files-missing");
|
|
112
|
+
expect(item.scopes).toEqual(["local"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("only the installed scopes with no version, user → project → local", () => {
|
|
116
|
+
// The project row is inherited from another checkout and not turned on
|
|
117
|
+
// here: it is neither installed nor missing, so it is not repaired.
|
|
118
|
+
const item = planFor(STRAY, [
|
|
119
|
+
info(STRAY, {
|
|
120
|
+
local: { installed: true },
|
|
121
|
+
project: { installed: false, version: "0.8.1" },
|
|
122
|
+
user: { installed: true },
|
|
123
|
+
}),
|
|
124
|
+
]);
|
|
125
|
+
expect(item.scopes).toEqual(["user", "local"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("control: a declared plugin installed nowhere is `absent`, with no scopes", () => {
|
|
129
|
+
const item = planFor(DEV, [info(DEV, {})], { [DEV]: "latest" });
|
|
130
|
+
expect(item.reason).toBe("absent");
|
|
131
|
+
expect(item.scopes).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("the engine reinstalls where the files are missing, never elsewhere", () => {
|
|
136
|
+
test("files missing at USER scope installs with --scope user, never project", async () => {
|
|
137
|
+
const item = planFor(STRAY, [info(STRAY, { user: { installed: true } })]);
|
|
138
|
+
const cli = fakeCli();
|
|
139
|
+
|
|
140
|
+
await applyPlugins([item], "/tmp/project", cli.deps);
|
|
141
|
+
|
|
142
|
+
// `REAL_PLUGIN_DEPS.install` is `installPlugin`, which passes this scope
|
|
143
|
+
// to `claude plugin install` as `--scope`.
|
|
144
|
+
expect(cli.installs).toEqual([{ id: STRAY, scope: "user" }]);
|
|
145
|
+
expect(cli.installs.map((c) => c.scope)).not.toContain("project");
|
|
146
|
+
expect(cli.updates).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("files missing at local scope installs with --scope local", async () => {
|
|
150
|
+
const item = planFor(STRAY, [info(STRAY, { local: { installed: true } })]);
|
|
151
|
+
const cli = fakeCli();
|
|
152
|
+
|
|
153
|
+
await applyPlugins([item], "/tmp/project", cli.deps);
|
|
154
|
+
|
|
155
|
+
expect(cli.installs).toEqual([{ id: STRAY, scope: "local" }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a progress meter counts it as work, so the bar reaches full", () => {
|
|
159
|
+
const item = planFor(STRAY, [info(STRAY, { user: { installed: true } })]);
|
|
160
|
+
expect(pluginNeedsWork(item)).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("control: an `absent` profile plugin still goes in at project scope", async () => {
|
|
164
|
+
const item = planFor(DEV, [info(DEV, {})], { [DEV]: "latest" });
|
|
165
|
+
const cli = fakeCli();
|
|
166
|
+
|
|
167
|
+
await applyPlugins([item], "/tmp/project", cli.deps);
|
|
168
|
+
|
|
169
|
+
expect(cli.installs).toEqual([{ id: DEV, scope: "project" }]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("repairing an undeclared user-scope plugin leaves profiles.json alone", () => {
|
|
174
|
+
let project: string;
|
|
175
|
+
let log: ReturnType<typeof spyOn>;
|
|
176
|
+
|
|
177
|
+
beforeEach(async () => {
|
|
178
|
+
project = await mkdtemp(join(tmpdir(), "files-missing-scope-"));
|
|
179
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
180
|
+
});
|
|
181
|
+
afterEach(async () => {
|
|
182
|
+
log.mockRestore();
|
|
183
|
+
await rm(project, { recursive: true, force: true });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("the full run — plan, apply under withCliBookkeeping, reconcile — writes nothing into profiles.json", async () => {
|
|
187
|
+
const manifest: ProfileManifest = {
|
|
188
|
+
version: 2,
|
|
189
|
+
profiles: { team: { name: "Team", plugins: { [DEV]: "latest" } } },
|
|
190
|
+
};
|
|
191
|
+
await writeManifest(manifest, project);
|
|
192
|
+
await activateProfile(manifest, "team", project, { onDisplace: () => {} });
|
|
193
|
+
const profilesPath = join(project, ".claude", "profiles.json");
|
|
194
|
+
const settingsPath = join(project, ".claude", "settings.json");
|
|
195
|
+
const before = await readFile(profilesPath, "utf8");
|
|
196
|
+
// Without an active marker withCliBookkeeping skips the reconcile, and
|
|
197
|
+
// this case would pass without testing anything.
|
|
198
|
+
expect(await readActiveMarker(project)).not.toBeNull();
|
|
199
|
+
|
|
200
|
+
// What `magus update` plans: the profile's plugin, installed and current
|
|
201
|
+
// at project scope, and an undeclared plugin installed at user scope with
|
|
202
|
+
// its files missing.
|
|
203
|
+
const plan = planPluginUpdates({ [DEV]: "latest" }, [
|
|
204
|
+
info(DEV, { project: { installed: true, version: "1.0.0" } }),
|
|
205
|
+
info(STRAY, { user: { installed: true } }),
|
|
206
|
+
]);
|
|
207
|
+
// `claude plugin install --scope project` turns the plugin on in the
|
|
208
|
+
// project's settings.json. The user-scope write lands in
|
|
209
|
+
// ~/.claude/settings.json, which a reconcile never reads.
|
|
210
|
+
const cli = fakeCli(async (id, scope) => {
|
|
211
|
+
if (scope !== "project") return;
|
|
212
|
+
const settings = await fs.readJson(settingsPath);
|
|
213
|
+
settings.enabledPlugins = { ...settings.enabledPlugins, [id]: true };
|
|
214
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings));
|
|
215
|
+
});
|
|
216
|
+
const reported: string[] = [];
|
|
217
|
+
|
|
218
|
+
await withCliBookkeeping(
|
|
219
|
+
project,
|
|
220
|
+
() => applyPlugins(plan, project, cli.deps),
|
|
221
|
+
{
|
|
222
|
+
// Exactly what `magus update` passes: the active profile's plugins.
|
|
223
|
+
installable: [DEV],
|
|
224
|
+
report: (line) => reported.push(line),
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// The outcome first — nothing absorbed — then the cause.
|
|
229
|
+
expect(reported).toEqual([]);
|
|
230
|
+
expect(await readFile(profilesPath, "utf8")).toBe(before);
|
|
231
|
+
const live = await fs.readJson(settingsPath);
|
|
232
|
+
expect(live.enabledPlugins?.[STRAY]).toBeUndefined();
|
|
233
|
+
expect(cli.installs).toEqual([{ id: STRAY, scope: "user" }]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { hasMissingFiles } from "../services/plugin-manager.js";
|
|
3
3
|
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* "
|
|
7
|
-
* identically to a healthy install: a
|
|
6
|
+
* "Files missing" — installed at a scope, with no version resolving — is a
|
|
7
|
+
* broken state that previously rendered identically to a healthy install: a
|
|
8
|
+
* green scope square and no version.
|
|
8
9
|
*
|
|
9
10
|
* It is also invisible to every update path, because the check is
|
|
10
11
|
* `installedVersion && compare(latest, installedVersion) > 0` — with no
|
|
@@ -19,18 +20,17 @@ const plugin = (over: Partial<PluginInfo>): PluginInfo =>
|
|
|
19
20
|
description: "",
|
|
20
21
|
marketplace: "magus",
|
|
21
22
|
marketplaceDisplay: "Magus",
|
|
22
|
-
enabled: true,
|
|
23
23
|
...over,
|
|
24
24
|
}) as PluginInfo;
|
|
25
25
|
|
|
26
|
-
describe("
|
|
27
|
-
test("
|
|
26
|
+
describe("hasMissingFiles", () => {
|
|
27
|
+
test("installed in project scope with no installed version is flagged", () => {
|
|
28
28
|
// The real case: terminal/code-search are in this project's enabledPlugins
|
|
29
29
|
// but have no registry entry for it and none at user scope.
|
|
30
30
|
expect(
|
|
31
|
-
|
|
31
|
+
hasMissingFiles(
|
|
32
32
|
plugin({
|
|
33
|
-
projectScope: {
|
|
33
|
+
projectScope: { installed: true },
|
|
34
34
|
installedVersion: undefined,
|
|
35
35
|
}),
|
|
36
36
|
),
|
|
@@ -39,53 +39,59 @@ describe("isEnabledButNotInstalled", () => {
|
|
|
39
39
|
|
|
40
40
|
test("a healthy install is not flagged", () => {
|
|
41
41
|
expect(
|
|
42
|
-
|
|
43
|
-
plugin({
|
|
42
|
+
hasMissingFiles(
|
|
43
|
+
plugin({
|
|
44
|
+
projectScope: { installed: true, version: "1.0.0" },
|
|
45
|
+
installedVersion: "1.0.0",
|
|
46
|
+
}),
|
|
44
47
|
),
|
|
45
48
|
).toBe(false);
|
|
46
49
|
});
|
|
47
50
|
|
|
48
|
-
test('
|
|
51
|
+
test('a "0.0.0" version means installed-with-unknown-version, not missing', () => {
|
|
49
52
|
// Anthropic's official plugins record exactly this. Treating it as missing
|
|
50
53
|
// would mislabel every one of them as broken.
|
|
51
54
|
expect(
|
|
52
|
-
|
|
53
|
-
plugin({
|
|
55
|
+
hasMissingFiles(
|
|
56
|
+
plugin({
|
|
57
|
+
userScope: { installed: true, version: "0.0.0" },
|
|
58
|
+
installedVersion: "0.0.0",
|
|
59
|
+
}),
|
|
54
60
|
),
|
|
55
61
|
).toBe(false);
|
|
56
62
|
});
|
|
57
63
|
|
|
58
|
-
test("not
|
|
59
|
-
expect(
|
|
60
|
-
|
|
61
|
-
)
|
|
64
|
+
test("not installed anywhere is not flagged — it is simply available", () => {
|
|
65
|
+
expect(hasMissingFiles(plugin({ installedVersion: undefined }))).toBe(
|
|
66
|
+
false,
|
|
67
|
+
);
|
|
62
68
|
});
|
|
63
69
|
|
|
64
70
|
test("orphaned plugins are excluded — they have their own 'deprecated' state", () => {
|
|
65
71
|
expect(
|
|
66
|
-
|
|
72
|
+
hasMissingFiles(
|
|
67
73
|
plugin({
|
|
68
74
|
isOrphaned: true,
|
|
69
|
-
projectScope: {
|
|
75
|
+
projectScope: { installed: true },
|
|
70
76
|
installedVersion: undefined,
|
|
71
77
|
}),
|
|
72
78
|
),
|
|
73
79
|
).toBe(false);
|
|
74
80
|
});
|
|
75
81
|
|
|
76
|
-
test("any
|
|
82
|
+
test("any installed scope counts, not just project", () => {
|
|
77
83
|
for (const scope of ["userScope", "projectScope", "localScope"] as const) {
|
|
78
|
-
expect(
|
|
79
|
-
|
|
80
|
-
)
|
|
84
|
+
expect(hasMissingFiles(plugin({ [scope]: { installed: true } }))).toBe(
|
|
85
|
+
true,
|
|
86
|
+
);
|
|
81
87
|
}
|
|
82
88
|
});
|
|
83
89
|
|
|
84
|
-
test("a scope present but
|
|
90
|
+
test("a scope present but not installed does not count", () => {
|
|
85
91
|
expect(
|
|
86
|
-
|
|
92
|
+
hasMissingFiles(
|
|
87
93
|
plugin({
|
|
88
|
-
projectScope: {
|
|
94
|
+
projectScope: { installed: false },
|
|
89
95
|
installedVersion: undefined,
|
|
90
96
|
}),
|
|
91
97
|
),
|