magus-cli 7.5.3 → 7.5.4
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__/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-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-state.test.ts +27 -22
- 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 +239 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -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 +39 -18
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -6
- 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 +7 -2
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useProfileStartupModal.ts +87 -7
- 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,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin's installed version comes only from scopes where it is INSTALLED —
|
|
3
|
+
* and "does this project hold a row?" reads only this project's own row.
|
|
4
|
+
*
|
|
5
|
+
* `getAvailablePlugins` took `installedVersion` from the project lookup whenever
|
|
6
|
+
* the plugin was installed anywhere, and that lookup resolves a row inherited
|
|
7
|
+
* from another checkout, installed here or not. So with the plugin installed at
|
|
8
|
+
* user scope at 0.9.0 beside an inherited project row at 0.8.1, the panel said
|
|
9
|
+
* "(v0.8.1 installed)" next to a status of current at 0.9.0; with its user files
|
|
10
|
+
* missing, the header said "● Installed" above a status saying files missing.
|
|
11
|
+
*
|
|
12
|
+
* The same kind of fallback sat under the apply engine's row check: the
|
|
13
|
+
* read-back fell back to the user row, so a plugin installed only at user scope,
|
|
14
|
+
* and turned on in project settings, ran `update --scope project` from a folder
|
|
15
|
+
* holding no project row.
|
|
16
|
+
*
|
|
17
|
+
* The pure halves — which row each question reads — are in
|
|
18
|
+
* scope-row-lookup.test.ts.
|
|
19
|
+
*
|
|
20
|
+
* The real `getAvailablePlugins` and the real registry read-back run in a CHILD
|
|
21
|
+
* process that starts with HOME, CLAUDE_CONFIG_DIR and XDG_* inside a temp dir:
|
|
22
|
+
* Bun's `os.homedir()` ignores a HOME changed at runtime, and the registry path
|
|
23
|
+
* is built from it. The child's `fetch` serves one catalog and 404s the rest, and
|
|
24
|
+
* its PATH holds only `git`, so no network and no `claude` CLI is reachable.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import {
|
|
30
|
+
mkdir,
|
|
31
|
+
mkdtemp,
|
|
32
|
+
readFile,
|
|
33
|
+
realpath,
|
|
34
|
+
rm,
|
|
35
|
+
symlink,
|
|
36
|
+
writeFile,
|
|
37
|
+
} from "node:fs/promises";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
41
|
+
import { type ReactNode, act } from "react";
|
|
42
|
+
import {
|
|
43
|
+
type PluginInfo,
|
|
44
|
+
hasMissingFiles,
|
|
45
|
+
} from "../services/plugin-manager.js";
|
|
46
|
+
import {
|
|
47
|
+
type PluginUpdateItem,
|
|
48
|
+
classifyPluginRows,
|
|
49
|
+
} from "../services/update-plan.js";
|
|
50
|
+
import type { InstalledPluginEntry, Marketplace } from "../types/index.js";
|
|
51
|
+
import {
|
|
52
|
+
type PluginPluginItem,
|
|
53
|
+
buildPluginBrowserItems,
|
|
54
|
+
} from "../ui/adapters/pluginsAdapter.js";
|
|
55
|
+
import { renderPluginDetail } from "../ui/renderers/pluginRenderers.js";
|
|
56
|
+
import { pluginRowText } from "../ui/renderers/pluginRowText.js";
|
|
57
|
+
|
|
58
|
+
const ID = "probe@magus";
|
|
59
|
+
const SRC = join(import.meta.dir, "..");
|
|
60
|
+
|
|
61
|
+
const MAGUS: Marketplace = {
|
|
62
|
+
name: "magus",
|
|
63
|
+
displayName: "Magus",
|
|
64
|
+
source: { source: "github", repo: "MadAppGang/magus" },
|
|
65
|
+
description: "",
|
|
66
|
+
owned: true,
|
|
67
|
+
} as Marketplace;
|
|
68
|
+
|
|
69
|
+
// ── the child ────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Loads the plugin list with the real `getAvailablePlugins`, and — when the job
|
|
73
|
+
* asks — plans it and applies it with the real engine and the real registry
|
|
74
|
+
* read-back, the Claude CLI's three commands recorded instead of run.
|
|
75
|
+
*/
|
|
76
|
+
const CHILD = `
|
|
77
|
+
const job = JSON.parse(await Bun.file(process.argv[2]).text());
|
|
78
|
+
const os = await import("node:os");
|
|
79
|
+
if (os.homedir() !== job.home) {
|
|
80
|
+
await Bun.write(job.out, JSON.stringify({ guard: false }));
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
globalThis.fetch = async (input) => {
|
|
84
|
+
const url = String(input instanceof Request ? input.url : input);
|
|
85
|
+
if (url === job.catalogUrl) {
|
|
86
|
+
return new Response(JSON.stringify(job.catalog), {
|
|
87
|
+
status: 200,
|
|
88
|
+
headers: { "content-type": "application/json" },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return new Response("", { status: 404, statusText: "Not Found" });
|
|
92
|
+
};
|
|
93
|
+
console.log = () => {};
|
|
94
|
+
const pm = await import(job.modules.pluginManager);
|
|
95
|
+
const plugins = await pm.getAvailablePlugins(job.project);
|
|
96
|
+
let apply = null;
|
|
97
|
+
if (job.apply) {
|
|
98
|
+
const plan = await import(job.modules.updatePlan);
|
|
99
|
+
const engine = await import(job.modules.updateEngine);
|
|
100
|
+
const item = plan.planPluginUpdates({}, plugins).find((i) => i.pluginId === job.id);
|
|
101
|
+
const calls = [];
|
|
102
|
+
const record = (command) => async (_id, scope) => { calls.push([command, scope]); };
|
|
103
|
+
await engine.applyPlugins([item], job.project, {
|
|
104
|
+
...engine.REAL_PLUGIN_DEPS,
|
|
105
|
+
install: record("install"),
|
|
106
|
+
update: record("update"),
|
|
107
|
+
repair: record("repair"),
|
|
108
|
+
saveInstalled: async () => {},
|
|
109
|
+
repairStaleLoad: async () => { throw new Error("no stale-load repair expected"); },
|
|
110
|
+
loadedVersion: undefined,
|
|
111
|
+
registryRows: undefined,
|
|
112
|
+
});
|
|
113
|
+
apply = { action: item.action, scopes: item.scopes, calls };
|
|
114
|
+
}
|
|
115
|
+
await Bun.write(job.out, JSON.stringify({ guard: true, plugins, apply }));
|
|
116
|
+
process.exit(0);
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
interface World {
|
|
120
|
+
base: string;
|
|
121
|
+
home: string;
|
|
122
|
+
/** A git repository: its root's registry rows are inherited by `project`. */
|
|
123
|
+
repo: string;
|
|
124
|
+
/** `<repo>/sub` — the directory magus runs in. */
|
|
125
|
+
project: string;
|
|
126
|
+
env: Record<string, string>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface Seed {
|
|
130
|
+
/** `~/.claude/settings.json` enabledPlugins. */
|
|
131
|
+
userEnabled?: Record<string, boolean>;
|
|
132
|
+
/** `<project>/.claude/settings.json` enabledPlugins. */
|
|
133
|
+
projectEnabled?: Record<string, boolean>;
|
|
134
|
+
/** The plugin's registry rows; `onDisk` creates the row's cache directory. */
|
|
135
|
+
rows: Array<{
|
|
136
|
+
scope: "user" | "project" | "local";
|
|
137
|
+
projectPath?: string;
|
|
138
|
+
version: string;
|
|
139
|
+
onDisk: boolean;
|
|
140
|
+
}>;
|
|
141
|
+
/** The version the magus catalog publishes for the plugin. */
|
|
142
|
+
catalogVersion: string;
|
|
143
|
+
/** Plan and apply the plugin, recording the Claude CLI calls. */
|
|
144
|
+
apply?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface ChildResult {
|
|
148
|
+
plugins: PluginInfo[];
|
|
149
|
+
apply: {
|
|
150
|
+
action: string;
|
|
151
|
+
scopes: string[];
|
|
152
|
+
calls: Array<[string, string]>;
|
|
153
|
+
} | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let root: string;
|
|
157
|
+
let binDir: string;
|
|
158
|
+
let childScript: string;
|
|
159
|
+
|
|
160
|
+
beforeAll(async () => {
|
|
161
|
+
root = await realpath(await mkdtemp(join(tmpdir(), "installed-version-")));
|
|
162
|
+
binDir = join(root, "bin");
|
|
163
|
+
await mkdir(binDir);
|
|
164
|
+
const git = Bun.which("git");
|
|
165
|
+
if (!git) throw new Error("git is required: inherited rows are found by it");
|
|
166
|
+
await symlink(git, join(binDir, "git"));
|
|
167
|
+
childScript = join(root, "child.ts");
|
|
168
|
+
await writeFile(childScript, CHILD);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
afterAll(async () => {
|
|
172
|
+
await rm(root, { recursive: true, force: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
let worlds = 0;
|
|
176
|
+
async function world(): Promise<World> {
|
|
177
|
+
const base = join(root, `world-${++worlds}`);
|
|
178
|
+
const home = join(base, "home");
|
|
179
|
+
const xdg = join(base, "xdg");
|
|
180
|
+
const repo = join(base, "repo");
|
|
181
|
+
const project = join(repo, "sub");
|
|
182
|
+
await mkdir(join(home, ".claude", "plugins"), { recursive: true });
|
|
183
|
+
for (const kind of ["config", "cache", "data", "state"]) {
|
|
184
|
+
await mkdir(join(xdg, kind), { recursive: true });
|
|
185
|
+
}
|
|
186
|
+
await mkdir(join(project, ".claude"), { recursive: true });
|
|
187
|
+
const env = {
|
|
188
|
+
PATH: `${binDir}:/usr/bin:/bin`,
|
|
189
|
+
HOME: home,
|
|
190
|
+
CLAUDE_CONFIG_DIR: join(home, ".claude"),
|
|
191
|
+
XDG_CONFIG_HOME: join(xdg, "config"),
|
|
192
|
+
XDG_CACHE_HOME: join(xdg, "cache"),
|
|
193
|
+
XDG_DATA_HOME: join(xdg, "data"),
|
|
194
|
+
XDG_STATE_HOME: join(xdg, "state"),
|
|
195
|
+
TMPDIR: tmpdir(),
|
|
196
|
+
NO_COLOR: "1",
|
|
197
|
+
TERM: "dumb",
|
|
198
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
199
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
200
|
+
};
|
|
201
|
+
const init = spawnSync("git", ["init", "-q", repo], { env });
|
|
202
|
+
if (init.status !== 0) throw new Error(`git init failed: ${init.stderr}`);
|
|
203
|
+
return { base, home, repo, project, env };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function writeJson(file: string, value: unknown): Promise<void> {
|
|
207
|
+
await mkdir(dirname(file), { recursive: true });
|
|
208
|
+
await writeFile(file, JSON.stringify(value, null, 2));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function run(w: World, seed: Seed): Promise<ChildResult> {
|
|
212
|
+
const claude = join(w.home, ".claude");
|
|
213
|
+
await writeJson(join(claude, "settings.json"), {
|
|
214
|
+
enabledPlugins: seed.userEnabled ?? {},
|
|
215
|
+
});
|
|
216
|
+
await writeJson(join(w.project, ".claude", "settings.json"), {
|
|
217
|
+
enabledPlugins: seed.projectEnabled ?? {},
|
|
218
|
+
});
|
|
219
|
+
const rows: InstalledPluginEntry[] = [];
|
|
220
|
+
for (const row of seed.rows) {
|
|
221
|
+
const installPath = join(
|
|
222
|
+
claude,
|
|
223
|
+
"plugins",
|
|
224
|
+
"cache",
|
|
225
|
+
"magus",
|
|
226
|
+
"probe",
|
|
227
|
+
row.version,
|
|
228
|
+
);
|
|
229
|
+
if (row.onDisk) await mkdir(installPath, { recursive: true });
|
|
230
|
+
rows.push({
|
|
231
|
+
scope: row.scope,
|
|
232
|
+
...(row.projectPath ? { projectPath: row.projectPath } : {}),
|
|
233
|
+
installPath,
|
|
234
|
+
version: row.version,
|
|
235
|
+
installedAt: "2026-09-01T00:00:00.000Z",
|
|
236
|
+
lastUpdated: "2026-09-01T00:00:00.000Z",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
await writeJson(join(claude, "plugins", "installed_plugins.json"), {
|
|
240
|
+
version: 2,
|
|
241
|
+
plugins: { [ID]: rows },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const out = join(w.base, "result.json");
|
|
245
|
+
const jobFile = join(w.base, "job.json");
|
|
246
|
+
await writeJson(jobFile, {
|
|
247
|
+
home: w.home,
|
|
248
|
+
project: w.project,
|
|
249
|
+
id: ID,
|
|
250
|
+
out,
|
|
251
|
+
apply: seed.apply ?? false,
|
|
252
|
+
catalogUrl:
|
|
253
|
+
"https://raw.githubusercontent.com/MadAppGang/magus/main/.claude-plugin/marketplace.json",
|
|
254
|
+
catalog: {
|
|
255
|
+
name: "magus",
|
|
256
|
+
plugins: [
|
|
257
|
+
{
|
|
258
|
+
name: "probe",
|
|
259
|
+
version: seed.catalogVersion,
|
|
260
|
+
description: "Probe plugin.",
|
|
261
|
+
source: "./plugins/probe",
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
modules: {
|
|
266
|
+
pluginManager: join(SRC, "services", "plugin-manager.ts"),
|
|
267
|
+
updatePlan: join(SRC, "services", "update-plan.ts"),
|
|
268
|
+
updateEngine: join(SRC, "services", "update-engine.ts"),
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const proc = spawnSync(
|
|
273
|
+
process.execPath,
|
|
274
|
+
["--no-env-file", childScript, jobFile],
|
|
275
|
+
{ cwd: w.project, env: w.env, timeout: 60_000 },
|
|
276
|
+
);
|
|
277
|
+
let raw: string;
|
|
278
|
+
try {
|
|
279
|
+
raw = await readFile(out, "utf8");
|
|
280
|
+
} catch {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`child produced no result (exit ${proc.status}):\n${proc.stderr}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const result = JSON.parse(raw) as ChildResult & { guard: boolean };
|
|
286
|
+
if (!result.guard) throw new Error("the child's home was not redirected");
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── the surfaces, rendered the way the Plugins screen renders them ───────────
|
|
291
|
+
|
|
292
|
+
async function detailText(item: PluginPluginItem): Promise<string> {
|
|
293
|
+
const { captureCharFrame, renderOnce, renderer } = await testRender(
|
|
294
|
+
<box flexDirection="column">
|
|
295
|
+
{renderPluginDetail(item, new Set<string>()) as ReactNode}
|
|
296
|
+
</box>,
|
|
297
|
+
{ width: 120, height: 80 },
|
|
298
|
+
);
|
|
299
|
+
await act(async () => {
|
|
300
|
+
await renderOnce();
|
|
301
|
+
await renderOnce();
|
|
302
|
+
});
|
|
303
|
+
const frame = captureCharFrame();
|
|
304
|
+
await act(async () => renderer.destroy());
|
|
305
|
+
return frame;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
interface Surfaces {
|
|
309
|
+
plugin: PluginInfo;
|
|
310
|
+
status: PluginUpdateItem;
|
|
311
|
+
row: string;
|
|
312
|
+
detail: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The screen's own path: classify, build the browser items, render. */
|
|
316
|
+
async function surfaces(plugins: PluginInfo[]): Promise<Surfaces> {
|
|
317
|
+
const items = buildPluginBrowserItems({
|
|
318
|
+
marketplaces: [MAGUS],
|
|
319
|
+
plugins,
|
|
320
|
+
collapsedMarketplaces: new Set(),
|
|
321
|
+
statuses: classifyPluginRows(plugins, {}),
|
|
322
|
+
});
|
|
323
|
+
const item = items.find(
|
|
324
|
+
(i): i is PluginPluginItem => i.kind === "plugin" && i.plugin.id === ID,
|
|
325
|
+
);
|
|
326
|
+
if (!item?.status) throw new Error(`no classified row for ${ID}`);
|
|
327
|
+
return {
|
|
328
|
+
plugin: item.plugin,
|
|
329
|
+
status: item.status,
|
|
330
|
+
row: pluginRowText(item).text,
|
|
331
|
+
detail: await detailText(item),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Finding 2 ────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe("installedVersion comes only from installed scopes", () => {
|
|
338
|
+
test("case B: installed at user v0.9.0 beside an inherited project row v0.8.1 — every surface says 0.9.0", async () => {
|
|
339
|
+
const w = await world();
|
|
340
|
+
const { plugins } = await run(w, {
|
|
341
|
+
userEnabled: { [ID]: true },
|
|
342
|
+
rows: [
|
|
343
|
+
{ scope: "user", version: "0.9.0", onDisk: true },
|
|
344
|
+
{
|
|
345
|
+
scope: "project",
|
|
346
|
+
projectPath: w.repo,
|
|
347
|
+
version: "0.8.1",
|
|
348
|
+
onDisk: false,
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
catalogVersion: "0.9.0",
|
|
352
|
+
});
|
|
353
|
+
const s = await surfaces(plugins);
|
|
354
|
+
|
|
355
|
+
// The reported shape: user installed at 0.9.0, the inherited row not on here.
|
|
356
|
+
expect(s.plugin.userScope).toEqual({ installed: true, version: "0.9.0" });
|
|
357
|
+
expect(s.plugin.projectScope).toEqual({
|
|
358
|
+
installed: false,
|
|
359
|
+
version: "0.8.1",
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(s.status.action).toBe("current");
|
|
363
|
+
expect(s.status.installed).toBe("0.9.0");
|
|
364
|
+
expect(s.plugin.installedVersion).toBe("0.9.0");
|
|
365
|
+
expect(s.plugin.installedVersion ?? null).toBe(s.status.installed);
|
|
366
|
+
expect(hasMissingFiles(s.plugin)).toBe(false);
|
|
367
|
+
expect(s.row).toBe(" v0.9.0");
|
|
368
|
+
expect(s.detail).toContain("● Installed");
|
|
369
|
+
expect(s.detail).not.toContain("files missing");
|
|
370
|
+
// Not "(v0.8.1 installed)", and not anywhere else either.
|
|
371
|
+
expect(s.detail).not.toContain("0.8.1");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("case A: files missing at user scope beside the same inherited row — every surface says files missing", async () => {
|
|
375
|
+
const w = await world();
|
|
376
|
+
const { plugins } = await run(w, {
|
|
377
|
+
userEnabled: { [ID]: true },
|
|
378
|
+
rows: [
|
|
379
|
+
{
|
|
380
|
+
scope: "project",
|
|
381
|
+
projectPath: w.repo,
|
|
382
|
+
version: "0.8.1",
|
|
383
|
+
onDisk: false,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
catalogVersion: "0.9.0",
|
|
387
|
+
});
|
|
388
|
+
const s = await surfaces(plugins);
|
|
389
|
+
|
|
390
|
+
expect(s.plugin.userScope).toEqual({ installed: true });
|
|
391
|
+
expect(s.plugin.projectScope).toEqual({
|
|
392
|
+
installed: false,
|
|
393
|
+
version: "0.8.1",
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(s.status.action).toBe("install");
|
|
397
|
+
expect(s.status.reason).toBe("files-missing");
|
|
398
|
+
expect(s.status.installed).toBeNull();
|
|
399
|
+
expect(s.plugin.installedVersion).toBeUndefined();
|
|
400
|
+
expect(hasMissingFiles(s.plugin)).toBe(true);
|
|
401
|
+
// No version beside "files missing" — the row has none to show.
|
|
402
|
+
expect(s.row).toBe(" files missing");
|
|
403
|
+
expect(s.detail).toContain("● Installed — files missing");
|
|
404
|
+
expect(s.detail).not.toContain("0.8.1");
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ── Finding 3 ────────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
describe("no row for this project → install, not update", () => {
|
|
411
|
+
test("a user row only, turned on in project settings: `install --scope project`, never `update`", async () => {
|
|
412
|
+
const w = await world();
|
|
413
|
+
const { apply } = await run(w, {
|
|
414
|
+
projectEnabled: { [ID]: true },
|
|
415
|
+
rows: [{ scope: "user", version: "1.0.0", onDisk: true }],
|
|
416
|
+
catalogVersion: "2.0.0",
|
|
417
|
+
apply: true,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Planned as project scope behind — the project resolves to the user row.
|
|
421
|
+
expect(apply?.action).toBe("update");
|
|
422
|
+
expect(apply?.scopes).toEqual(["project"]);
|
|
423
|
+
expect(apply?.calls).toEqual([["install", "project"]]);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -23,7 +23,6 @@ const plugin = (overrides: Partial<PluginInfo> = {}): PluginInfo => ({
|
|
|
23
23
|
description: "",
|
|
24
24
|
marketplace: "magus",
|
|
25
25
|
marketplaceDisplay: "Magus",
|
|
26
|
-
enabled: true,
|
|
27
26
|
...overrides,
|
|
28
27
|
});
|
|
29
28
|
|
|
@@ -43,9 +42,9 @@ describe("areMatesAvailable", () => {
|
|
|
43
42
|
test("true when it is installed at ANY scope", async () => {
|
|
44
43
|
// Any scope, because a mate is served by whatever Claude Code has loaded when the
|
|
45
44
|
// agent spawns — the union of the three. A user-scope install serves a project that
|
|
46
|
-
//
|
|
45
|
+
// installs nothing of its own.
|
|
47
46
|
for (const scope of ["userScope", "projectScope", "localScope"] as const) {
|
|
48
|
-
const one = plugin({ [scope]: {
|
|
47
|
+
const one = plugin({ [scope]: { installed: true, version: "1.0.0" } });
|
|
49
48
|
expect({
|
|
50
49
|
scope,
|
|
51
50
|
available: await areMatesAvailable(undefined, lister([one])),
|
|
@@ -54,27 +53,26 @@ describe("areMatesAvailable", () => {
|
|
|
54
53
|
});
|
|
55
54
|
|
|
56
55
|
/**
|
|
57
|
-
*
|
|
56
|
+
* Files missing does not serve a mate.
|
|
58
57
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* on the strength of it would promise routing through a plugin that never loaded.
|
|
58
|
+
* Installed with no version is the broken state the plugin list renders as "files
|
|
59
|
+
* missing": Claude Code wants to load the plugin and has nothing to load — and a mate row
|
|
60
|
+
* drawn on the strength of it would promise routing through a plugin that never loaded.
|
|
63
61
|
*/
|
|
64
|
-
test("false when it is
|
|
65
|
-
const flagged = plugin({ userScope: {
|
|
62
|
+
test("false when it is installed but its files are missing", async () => {
|
|
63
|
+
const flagged = plugin({ userScope: { installed: true } });
|
|
66
64
|
expect(await areMatesAvailable(undefined, lister([flagged]))).toBe(false);
|
|
67
65
|
});
|
|
68
66
|
|
|
69
|
-
test("false when it is installed
|
|
67
|
+
test("false when only a registry row exists and it is not installed", async () => {
|
|
70
68
|
const off = plugin({
|
|
71
|
-
userScope: {
|
|
69
|
+
userScope: { installed: false, version: "1.0.0" },
|
|
72
70
|
});
|
|
73
71
|
expect(await areMatesAvailable(undefined, lister([off]))).toBe(false);
|
|
74
72
|
});
|
|
75
73
|
|
|
76
74
|
test('a version of "0.0.0" still counts — that is how some plugins record themselves', async () => {
|
|
77
|
-
const zero = plugin({ userScope: {
|
|
75
|
+
const zero = plugin({ userScope: { installed: true, version: "0.0.0" } });
|
|
78
76
|
expect(await areMatesAvailable(undefined, lister([zero]))).toBe(true);
|
|
79
77
|
});
|
|
80
78
|
|